# Reading Meta (/docs/text/reading-meta)



<ComponentShowcase name="reading-meta" />

## Installation [#installation]

```bash
npx shadcn@latest add https://ui.zyeon.ai/r/reading-meta.json
```

## Prompt [#prompt]

```text
Build a React + TypeScript + Tailwind "ReadingMeta" component — the byline strip
above an article. It composes two registry pieces (AvatarGroup, RelativeTime) and
two shadcn primitives (Badge, Tooltip); lucide-react supplies Clock3 / CircleCheck.

Contract
- forwardRef<HTMLDivElement>, remaining props spread on the root, className merged
  through cn().
- Props: authors: { name, href?, avatarUrl? }[]; publishedAt: string (ISO 8601);
  updatedAt?: string; words: number; wordsPerMinute? = 238; progress? (0–1);
  locale: string (required, BCP 47); timeZone? = "UTC" (IANA); maxAuthors? = 2.
- locale is required and the zone defaults rather than inherits on purpose: an
  implicit Intl locale/zone formats one way on a UTC server and another in a
  browser, which is a hydration mismatch that only appears either side of
  midnight.

Behavior
- Reading time is derived in-component and lives in ONE helper so no two figures
  can disagree: minutes = ceil(words / wordsPerMinute), floored at 1 minute for a
  non-empty body. Rounding up is deliberate — a 7.2-minute piece sold as "7 min"
  undershoots every reader.
- The estimate answers whichever question is live: no progress (or exactly 0) →
  "N min read" (the whole article); 0 < progress < 1 → "N min left", the same rule
  applied to round(words × (1 − progress)) with a one-word floor; progress === 1 →
  "Finished". Only an exact 1 earns "Finished", so 99.8% still reports a minute
  left instead of rounding a reader over the line.
- The pace is never unexplained: the chip reveals a breakdown stating length,
  words per minute, the full read, and read/left percentages. Read and left are a
  two-part partition, so they are rounded once and complemented (the n=2 case of
  largest remainder) and always total exactly 100; both ends clamp to 1/99 so a
  bar that has visibly moved is never labelled 0%, and 99.6% is never "100% read".
- Both hidden disclosures — the breakdown and the collapsed names — open on click
  as well as on hover and focus. A hover tooltip never opens for a touch pointer,
  and a byline is read on a phone more than anywhere else, so hover/focus preview
  in a tooltip while a tap pins the same content into the strip: the breakdown
  under the row, the folded names under the byline. Each trigger carries
  aria-expanded plus aria-controls pointing at the panel it pins.
- With no countable body (words < 1) no minute figure is claimed at all — the chip
  falls back to the percentage if progress exists, or disappears, and it stops
  being a focus stop because it has nothing to reveal.
- Dates: parse the ISO strings, drop unparseable ones (an Invalid Date fed to Intl
  throws a RangeError), render "Published <medium date>" and, only when strictly
  later than publication, "Updated <medium date>". Feeds routinely echo
  publishedAt into updatedAt, and "Updated" beside the same date claims an edit
  nobody made.
- Each stamp is a real <time dateTime={iso}> plus a relative label. The relative
  half needs the reader's clock, which the server does not have, so it renders
  only after hydration (useSyncExternalStore with getServerSnapshot → false);
  crawlers and no-JS readers still get the absolute date. Both halves share one
  title: date + time + zone via timeStyle "long" — dateStyle/timeStyle cannot be
  combined with timeZoneName, Intl throws a TypeError if you try.
- Authors: slice at maxAuthors and join the visible names with
  Intl.ListFormat.formatToParts, so each name stays its own <a> (or plain text
  without href — never a dead link) while the locale supplies the conjunction
  ("A, B, and C" in en-US, "A、B和C" in zh-CN). The tail is passed to Intl as the
  final element, "N others", and is a trigger: hover/focus previews the collapsed
  names, a click pins them under the byline, and an aria-label spells them out
  either way. AvatarGroup collapses at the same max, so its "+N" disc and the
  "N others" phrase are the same subtraction.
- Progress bar (only when progress is supplied): role="progressbar" with
  aria-valuenow/valuetext, filled to the same clamped percent the tooltip prints.

Rendering & styling
- Semantic tokens only: text-foreground names, text-muted-foreground meta line,
  bg-muted track, bg-primary fill, secondary Badge for the chip. No literal
  colours anywhere.
- One wrapping row (avatars · names + dates · chip pushed to the end with ms-auto)
  over a full-width 1px bar; every cluster is min-w-0 so a long name wraps instead
  of overflowing at ~420px.
- Root is role="group" + aria-label="Article byline", both before the spread so a
  consumer can relabel. Links, the overflow phrase and the chip all take
  focus-visible rings; the bar's width transition is dropped under
  motion-reduce, and nothing depends on that animation to be readable.

Customization levers
- Density: drop the avatars for a text-only byline, or move the chip onto its own
  row by removing ms-auto — the arithmetic is untouched either way.
- Pace and unit: wordsPerMinute is the single dial; for CJK bodies pass characters
  as words and a characters-per-minute figure, and the tooltip states whatever you
  chose.
- Byline width: maxAuthors decides where "and N others" starts; set it above the
  author count to never collapse.
- Zone and locale: swap timeZone for the reader's IANA zone once you know it, and
  locale for their tag; the medium/long date styles are the two lines to retune.
- Progress surface: keep the bar, or delete it and let the "N min left" chip carry
  the state alone; the same clamped percent drives both.
- Copy: every word the strip prints is English and written inline — `locale` buys
  you the dates, the number formatting and the list conjunction, nothing else. A
  localised surface has to lift all of it into one `copy` prop (or a per-locale
  lookup), not just the labels: the byline prefix "By"; the stamps "Published" /
  "Updated"; the chip's "Finished" / "N min left" / "N min read" / "N% read"; the
  overflow phrase "N other(s)", whose singular/plural is a hand-written English
  ternary rather than Intl.PluralRules; the breakdown rows "Length" / "Pace" /
  "Full read" / "Read" / "Left" and their units "words" / "words per minute"; the
  ", and " joiner used only when the engine has no Intl.ListFormat; and the
  screen-reader-only strings — "Article byline", "Reading progress", the chip's
  spoken summary and the bar's "N% read, N min left". Lift four of them and you
  ship the half-localised byline the two-locale demo card shows: English labels
  welded onto Chinese dates.
```

## Concepts [#concepts]

<Mermaid
  chart="`flowchart TD
A[&#x22;props: authors, ISO stamps, words, progress&#x22;] --> B[&#x22;Intl, explicit locale + zone&#x22;]
B --> C[&#x22;readable date: server = client&#x22;]
C --> D[&#x22;relative label joins after hydration&#x22;]
A --> E[&#x22;one rule: ceil(words / wpm), floor 1 min&#x22;]
E --> F{&#x22;progress supplied?&#x22;}
F -->|&#x22;none or 0&#x22;| G[&#x22;'N min read' — the whole article&#x22;]
F -->|&#x22;0 < p < 1&#x22;| H[&#x22;'N min left' — same rule, fewer words&#x22;]
F -->|&#x22;exactly 1&#x22;| I[&#x22;'Finished'&#x22;]
E --> J[&#x22;tooltip states words + wpm&#x22;]
F --> K[&#x22;read% + left% = 100&#x22;]
A --> L[&#x22;slice at maxAuthors&#x22;]
L --> M[&#x22;Intl.ListFormat.formatToParts&#x22;]
M --> N[&#x22;each name keeps its link, tail = 'N others'&#x22;]
L --> O[&#x22;'+N' avatar disc, same subtraction&#x22;]`"
/>

* **Derived, not asserted** — the total and the remainder come out of one `ceil(words / pace)` helper applied to two word counts, so the chip and its tooltip cannot drift apart the way a hand-written "8 min read" in the CMS does.
* **Remaining beats total** — before the first scroll the useful number is what the article costs; after it, only what is left. The same prop switches the question, and the answer ends at zero.
* **Stated assumption** — a minute count is a claim about a reader, so the pace it divided by is one hover (or one tap) away instead of buried in a build script.
* **Claim only what parses** — an unparseable date disappears, an `updatedAt` that merely echoes `publishedAt` is not an update, and a zero word count buys no estimate at all.
* **Collapse without deleting** — long bylines fold into "and N others", but the folded names stay reachable on hover, on focus and on tap: hovering previews them, clicking pins them into the byline, because a hover tooltip never opens for a touch pointer and a contributor a phone cannot reach has not been credited.
* **Hydration-safe halves** — the absolute date renders identically on both sides because the locale and zone are explicit; the relative half, which needs the visitor's clock, waits for the client.
