Media

Article Reader

A long-form reader with a scoped scroll-progress bar, drop-cap opening paragraph, and pull-quote styling.

Install

npx shadcn@latest add @paragon/article-reader

article-reader.tsx

"use client";

import * as React from "react";
import { cn } from "@/lib/utils";

export interface ArticleReaderProps extends React.ComponentProps<"article"> {
  category?: string;
  title?: string;
  byline?: string;
  dateline?: string;
  /** Reading-time label, e.g. "6 min read". */
  readingTime?: string;
  /** Body: paragraphs of text, optionally interleaved with pull quotes. */
  children?: React.ReactNode;
}

/**
 * A long-form NYT-style reader: a scoped scroll-progress bar pinned to the top
 * of the article, a drop-cap first paragraph, and pull-quote styling. The
 * progress bar tracks THIS article's own scroll within a bounded, scrollable
 * container (not the window), so it works inside a docs preview. It updates via
 * a rAF-throttled scroll listener and is decorative/aria-hidden.
 */
export function ArticleReader({
  category = "Business",
  title = "The Quiet Restructuring Reshaping Corporate Finance",
  byline = "By Dana Whitfield",
  dateline = "March 14, 2026",
  readingTime = "6 min read",
  className,
  children,
  ...props
}: ArticleReaderProps) {
  const scrollRef = React.useRef<HTMLDivElement>(null);
  const barRef = React.useRef<HTMLDivElement>(null);

  React.useEffect(() => {
    const el = scrollRef.current;
    const bar = barRef.current;
    if (!el || !bar) return;
    let frame = 0;
    const update = () => {
      frame = 0;
      const max = el.scrollHeight - el.clientHeight;
      const pct = max > 0 ? Math.min(1, el.scrollTop / max) : 0;
      bar.style.transform = `scaleX(${pct})`;
    };
    const onScroll = () => {
      if (frame) return;
      frame = requestAnimationFrame(update);
    };
    update();
    el.addEventListener("scroll", onScroll, { passive: true });
    return () => {
      el.removeEventListener("scroll", onScroll);
      if (frame) cancelAnimationFrame(frame);
    };
  }, []);

  const body = children ?? <DefaultBody />;

  return (
    <div
      className={cn(
        "relative w-full max-w-xl overflow-hidden rounded-xl bg-card text-card-foreground shadow-border",
        className,
      )}
    >
      <div
        aria-hidden
        className="absolute inset-x-0 top-0 z-10 h-0.5 bg-border/60"
      >
        <div
          ref={barRef}
          className="h-full origin-left bg-foreground"
          style={{ transform: "scaleX(0)" }}
        />
      </div>
      <div
        ref={scrollRef}
        className="max-h-[26rem] overflow-y-auto overscroll-contain"
      >
        <article
          data-slot="article-reader"
          className="px-6 py-6 [&_p+p]:mt-4"
          {...props}
        >
          <header className="mb-5">
            <span className="text-xs font-semibold tracking-wide text-muted-foreground uppercase">
              {category}
            </span>
            <h1 className="mt-2 text-2xl font-semibold leading-tight tracking-tight text-balance">
              {title}
            </h1>
            <p className="mt-3 flex flex-wrap items-center gap-x-2 text-xs text-muted-foreground">
              <span className="font-medium text-foreground">{byline}</span>
              <span aria-hidden>·</span>
              <span>{dateline}</span>
              <span aria-hidden>·</span>
              <span className="tabular-nums">{readingTime}</span>
            </p>
          </header>
          <div className="text-[15px] leading-relaxed text-foreground/90 [&_.drop-cap]:first-letter:mr-2 [&_.drop-cap]:first-letter:float-left [&_.drop-cap]:first-letter:text-5xl [&_.drop-cap]:first-letter:leading-[0.8] [&_.drop-cap]:first-letter:font-semibold">
            {body}
          </div>
        </article>
      </div>
    </div>
  );
}

export function PullQuote({ children }: { children: React.ReactNode }) {
  return (
    <blockquote className="my-5 border-l-2 border-foreground pl-4 text-lg font-medium leading-snug text-balance text-foreground">
      {children}
    </blockquote>
  );
}

function DefaultBody() {
  return (
    <>
      <p className="drop-cap">
        For years the maneuver was treated as a footnote — a technical
        adjustment buried deep in the appendix of an annual report. Today it
        sits at the center of how the largest firms think about their balance
        sheets, and the analysts who once ignored it are now paid handsomely to
        read between its lines.
      </p>
      <p>
        The shift did not arrive with fanfare. It crept in quarter by quarter,
        as treasurers discovered that a modest change in how obligations were
        classified could reshape the numbers investors watched most closely.
      </p>
      <PullQuote>
        “Nobody restructures loudly. The whole point is that the market barely
        notices until the next earnings call.”
      </PullQuote>
      <p>
        What follows is a look inside that quiet machinery — the incentives, the
        oversight gaps, and the handful of decisions that separate a routine
        filing from a full restatement.
      </p>
      <p>
        Regulators have taken note. A working group convened last autumn is
        weighing new disclosure rules that would drag the practice back into
        plain view, forcing firms to explain in ordinary language what today
        requires a specialist to decode.
      </p>
    </>
  );
}