# Blog Article (Moonarc)

The page of one blog post: a header with the date, the reading time, the title, the author, the tags and the cover, then the body in a measured column that a beam fills as it is read, beside a contents list that follows the reader. The body is your rendered Markdown or MDX, and the contents list comes from the headings Astro returns with it. No script of its own: the beam is TracingBeam, the list is ReadingToc.

- Pro block. Install: `npx shadcn@latest add @moonarc-pro/blog-article` (license key required)
- Tier B · category section · trigger scroll, click
- Readout: `<BlogArticle title={post.data.title} date={post.data.pubDate} headings={headings}>`
- Browser support: limited (Chrome 115 · not Firefox · Safari 26); elsewhere: Firefox shows the beam as a full, static line with its dot at the end; the contents list still marks the current heading, and the post reads the same.
- Measured cost: 0 B JS of its own · uses ReadingToc + runtime (with dependencies 2.6 kB raw; CSS 15.8 kB raw)
- Page: https://moonarc.dev/components/blog-article/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `title` | `string` | none | The post's title: the h1 unless `level` says 2. Required. |
| `level` | `1 | 2` | `1` | Heading level of the title: 1 on the post page, 2 under another h1 (a demo). Write the body's headings from h2 either way. |
| `date` | `string | Date` | none | Publication day. A string prints as given and is the datetime; a Date (a collection's z.coerce.date()) prints through Intl.DateTimeFormat in UTC, the day the file names, with that day in ISO form as the datetime. BlogGrid prints the same day the same way. |
| `updated` | `string | Date` | none | Day of the last real change, printed after the date through updatedLabel as a second <time>. |
| `locale` | `string` | `unset (the runtime's)` | Intl locale a Date prints in. |
| `dateStyle` | `'full' | 'long' | 'medium' | 'short'` | `'medium'` | Intl date style of a Date (medium: Mar 2, 2026). |
| `updatedLabel` | `string` | `'Updated {date}'` | The updated line; {date} becomes the <time>. Without {date} the build fails. |
| `author` | `{ name, role?, avatar? }` | none | Who wrote it. avatar is a URL or an imported image (its src, width and height are used); it is decorative (alt="") because the name is printed beside it. |
| `tags` | `string[]` | `[]` | Tags, printed as a list under the author. |
| `minutes` | `number` | none | Reading time in minutes, rounded, at least 1. Compute it at build from the body's word count. |
| `minutesLabel` | `string` | `'{n} min read'` | The reading-time text; {n} is the number. Without {n} the build fails. |
| `cover` | `{ src, alt }` | none | The cover image: a URL or an imported image. Its transition name is ma-cover- and the page's path, the name BlogGrid gives the same post's card, so the router morphs one into the other. alt describes the picture, or is empty for a decorative one. |
| `headings` | `{ depth, slug, text }[]` | `[]` | Astro's headings from render(): depth 2 and 3 become the contents list, linking to the ids the Markdown pipeline wrote. |
| `toc` | `boolean` | `true` | Show the contents list. It is rendered only when two headings or more qualify. |
| `tocTitle` | `string` | `'On this page'` | The contents list's visible title and the name of its nav. |
| `offset` | `number` | `80` | Height of the page's sticky bar in px. The side column sticks below it, a jump to a heading stops below it (scroll-margin), and ReadingToc counts a heading as current from that line down. |
| `back` | `{ href, label }` | none | A link back above the title (the blog index); its arrow turns in a right-to-left document. |
| `prev` | `{ href, title }` | none | The previous post, linked under the body with rel="prev". |
| `next` | `{ href, title }` | none | The next post, linked under the body with rel="next". |
| `prevLabel` | `string` | `'Previous'` | The small line above the previous post's title. |
| `nextLabel` | `string` | `'Next'` | The small line above the next post's title. |
| `pagerLabel` | `string` | `'More posts'` | The name of the nav that holds the two links. |
| `tone` | `'surface' | 'muted' | 'ink' | 'brand'` | `unset (paints nothing)` | The ground: the page surface, a muted band, an always-dark ink panel, or a deep brand panel. Each paints --background, --ma-tone-muted (over --muted, with a --border hairline), --ma-tone-ink or --ma-tone-brand (over --primary) with the matching text. With theme.css, a painted tone also re-declares --card, --border, --input and --ring inside the section. The beam is the theme's primary on an unset, surface or muted ground and the tone's text on ink and brand; the contents list's rule and mark follow the text. |
| `density` | `'compact' | 'cozy' | 'roomy'` | `'cozy'` | Block padding and the main gaps ×0.8 / ×1 / ×1.25 through --ma-density. |

## Usage

```astro
---
import BlogArticle from '@/components/moonarc/pro/BlogArticle.astro';
import { getEntry, render } from 'astro:content';
// src/pages/blog/[slug].astro: the post from your collection, rendered; headings are the ids the Markdown pipeline wrote
const post = await getEntry('posts', Astro.params.slug!);
const { Content, headings } = await render(post!);
---
<BlogArticle
  title={post.data.title}
  date={post.data.pubDate}
  updated={post.data.updated}
  author={{ name: 'Ada Park', role: 'Engineering' }}
  tags={post.data.tags}
  minutes={6}
  cover={{ src: post.data.cover.src, alt: post.data.cover.alt }}
  headings={headings}
  offset={64}
  back={{ href: '/blog/', label: 'All posts' }}
  next={{ href: '/blog/next-post/', title: 'The next post' }}
>
  <Content />
</BlogArticle>

<!-- A post imports what it uses itself (HighlightMarker, CodeTabs, Lightbox, LinkPreview): they are the post's, not the
     section's, so a post without them ships none of their JavaScript. Code fences go through Shiki at build; pass
     markdown.shikiConfig the readableCode() transformer so their colours keep 4.5 : 1. -->
```

## Reduced motion

The beam is a full, still line, the contents list moves its mark without easing, and the back arrow does not travel. The list still marks the current heading.

## With ClientRouter

ReadingToc binds through the runtime's onMount and drops its scroll listener before every swap, so a post reached by a ClientRouter navigation has one listener. The cover's transition name pairs it with BlogGrid's card for the same post.

## Craft

- The body is a slot, so the post is whatever your Markdown or MDX renders. The components a post uses are imported by the post: a post without a lightbox ships no lightbox script.
- The contents list is built from the headings Astro returns with the rendered post, so its links are the ids the Markdown pipeline wrote, never ids guessed from the text.
- The side column sticks under the page's bar (offset) and is capped at the viewport's height minus that bar, so a long list scrolls inside it instead of running off the screen. Under 60 rem of the section's width, the list moves above the body as a plain list.
- The body is capped at 70 characters a line. Headings jump to a line below the sticky bar through scroll-margin, and code blocks scroll sideways inside the measure.
- The beam sits in the first column of TracingBeam's grid, so a right-to-left document puts it on the right with no rule of its own.
- A Date prints in UTC through Intl, as BlogGrid prints it, so the card and the post name the same day for a date written without a time.

## Replaces

- Blog post layouts (Tailwind Plus)
- Article pages (shadcnblocks)
- TracingBeam post layouts (Aceternity)

## Source

Pro block. The source is served by the license-gated registry: `npx shadcn@latest add @moonarc-pro/blog-article` with a key in components.json (https://moonarc.dev/account/). Everything it composes is free: tracing-beam, reading-toc, page-transition.
