Reading Meta
An article byline strip that derives its reading estimate from the word count and switches from the total to what is left once the reader has started.
Loading preview…
Installation
npx shadcn@latest add https://ui.zyeon.ai/r/reading-meta.jsonPrompt
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
- 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
updatedAtthat merely echoespublishedAtis 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.
Math Formula
A documented LaTeX subset rendered as MathML with a styled CSS fallback — inline and block display, an accessible sentence derived from the source, copy-as-LaTeX, and an explicit refusal for anything outside the subset.
Grid Dots
A pure-CSS grid, dot or cross pattern container with a radial fade — a zero-JS decorative backdrop for hero and section surfaces.