Text

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.

Preview in your theme

Loading preview…

"use client"

import * as React from "react"
import { CircleCheck, Clock3 } from "lucide-react"

import { Badge } from "@/components/ui/badge"
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip"
import { cn } from "@/lib/utils"
import { AvatarGroup, type AvatarGroupItem } from "@/registry/ui/avatar-group"
import { RelativeTime } from "@/registry/ui/relative-time"

/* -------------------------------------------------------------------------- *
 * Reading time
 *

Installation

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

Prompt

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 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.

On This Page