# Agency: Journal

> The journal index as a ruled list with drawn covers, and a post page with its author, a wide cover, the text in one reading column and more posts.

A shadcn/ui block from the [@eased](https://eased.dev/?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/eased/agency-journal).

- Page, with a live preview: https://shadcn.exchange/r/eased/agency-journal
- Screenshot: https://shadcn.exchange/shot/eased/agency-journal/light.png
- Registry item: https://eased.dev/r/agency-journal.json
- Listed: 2026-10-23, source last changed 2026-10-23

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/eased/agency-journal.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `motion`
- Registry items: `https://eased.dev/r/eased-tokens.json`, `https://eased.dev/r/eased-motion.json`, `https://eased.dev/r/agency-core.json`, `https://eased.dev/r/avatar.json`, `https://eased.dev/r/badge.json`

## Files

### `src/registry/eased/templates/agency/journal.tsx`

```tsx
"use client";

import { Avatar } from "../../avatar/avatar";
import { Badge } from "../../badge/badge";
import { journal, posts, type Post, type PostBlock } from "./content-journal";
import { NoteCover } from "./covers";
import { COLUMN, LABEL, PageHead, Reveal, Section, formatDay, useHref } from "./kit";

/**
 * The journal index, /journal: every post as a row of one ruled grid, a small drawn cover beside
 * the kind and date, the title and its line, so the titles all start on one x (rule 103).
 */
export function JournalIndex() {
  const to = useHref();
  return (
    <section aria-labelledby="page-title" className={`${COLUMN} pt-section-sm pb-group`}>
      <PageHead title={journal.title} lead={journal.lead} />
      <ol className="mt-group border-t border-line-subtle">
        {posts.map((post, i) => (
          <li key={post.slug}>
            <Reveal
              index={i}
              className="group relative grid grid-cols-[6rem_minmax(0,1fr)] gap-x-pad-md border-b border-line-subtle py-pad-md sm:grid-cols-[10rem_minmax(0,1fr)] lg:grid-cols-[14rem_minmax(0,1fr)] lg:gap-x-pad-lg"
            >
              <div aria-hidden className="image-ring relative aspect-square overflow-hidden rounded-panel bg-surface-sunken text-text lg:aspect-[3/2]">
                <NoteCover art={post.art} />
              </div>
              <div className="flex min-w-0 flex-col gap-1.5 lg:gap-in">
                <Meta post={post} />
                <h2 className="text-display-xl font-medium text-pretty text-text sm:text-display-2xl">
                  <a
                    href={to(`/journal/${post.slug}`)}
                    className="focus-ring rounded-control-xs transition-colors duration-(--eased-duration-fast) ease-standard group-hover-fine:text-text-secondary after:absolute after:inset-0"
                  >
                    {post.title}
                  </a>
                </h2>
                <p className="max-w-2xl text-ui-md text-pretty text-text-secondary max-sm:hidden">{post.dek}</p>
              </div>
            </Reveal>
          </li>
        ))}
      </ol>
    </section>
  );
}

/** The kind as a badge and the date, as the latest strip shows them. */
function Meta({ post, minutes = false }: { post: Post; minutes?: boolean }) {
  return (
    <p className="flex flex-wrap items-center gap-2 text-ui-sm text-text-muted">
      <Badge tone={post.kind === "Press" ? "brand" : "neutral"}>{post.kind}</Badge>
      <time dateTime={post.date} className="tabular-nums">
        {formatDay(post.date)}
      </time>
      {minutes && (
        <span className="tabular-nums">
          · {post.minutes} {journal.read}
        </span>
      )}
    </p>
  );
}

/**
 * A post, /journal/<slug>: its kind, date and title over a line and the author, a wide drawn
 * cover, then the text in one reading column, and the other posts under it.
 */
export function JournalPost({ slug }: { slug: string }) {
  const post = posts.find((p) => p.slug === slug) ?? posts[0];
  const others = posts.filter((p) => p.slug !== post.slug).slice(0, 3);

  return (
    <>
      <article aria-labelledby="page-title" className={`${COLUMN} pt-section-sm pb-group`}>
        <PageHead back={journal.back} title={post.title} lead={post.dek} size="md">
          <Meta post={post} minutes />
          <p className="flex items-center gap-2 text-ui text-text-secondary">
            <Avatar name={post.author} size="sm" />
            {journal.by} {post.author}
          </p>
        </PageHead>
        <Reveal index={1} className="mt-group">
          <div aria-hidden className="image-ring relative aspect-[2/1] overflow-hidden rounded-surface bg-surface-sunken text-text sm:aspect-[3/1]">
            <NoteCover art={post.art} />
          </div>
        </Reveal>
        <Reveal index={2} className="mx-auto mt-group flex max-w-[40rem] flex-col gap-pad-base">
          {post.body.map((block, i) => (
            <Block key={i} block={block} />
          ))}
        </Reveal>
      </article>

      <Section id="more" label={journal.more}>
        <ul className="grid gap-x-tiles gap-y-pad-lg sm:grid-cols-3">
          {others.map((p, i) => (
            <li key={p.slug}>
              <Reveal index={i}>
                <PostCard post={p} />
              </Reveal>
            </li>
          ))}
        </ul>
      </Section>
    </>
  );
}

/** One block of a post's text. */
function Block({ block }: { block: PostBlock }) {
  if (block.type === "h2") return <h2 className="mt-pad-md text-display-xl font-medium text-balance text-text">{block.text}</h2>;
  if (block.type === "quote")
    return (
      <figure className="my-pad-md flex flex-col gap-in border-l-2 border-(--tessera-tile) pl-pad-md">
        <blockquote className="font-serif text-display-2xl text-pretty text-text">{block.text}</blockquote>
        {block.by && <figcaption className="text-ui text-text-muted">{block.by}</figcaption>}
      </figure>
    );
  if (block.type === "list")
    return (
      <ul className="flex flex-col gap-2.5">
        {block.items.map((item) => (
          <li key={item} className="grid grid-cols-[1rem_minmax(0,1fr)] items-baseline gap-2 text-ui-md text-pretty text-text-secondary sm:text-ui-lg">
            <span aria-hidden className="h-px w-2.5 translate-y-[-0.3em] bg-text-muted" />
            {item}
          </li>
        ))}
      </ul>
    );
  return <p className="text-ui-md text-pretty text-text-secondary sm:text-ui-lg">{block.text}</p>;
}

/** A small card for another post: cover, date and title, the whole card one link (rule 59). */
function PostCard({ post }: { post: Post }) {
  const to = useHref();
  return (
    <article className="group relative flex flex-col gap-in">
      <div aria-hidden className="image-ring relative aspect-[3/2] overflow-hidden rounded-panel bg-surface-sunken text-text">
        <NoteCover art={post.art} />
      </div>
      <div className="flex flex-col gap-1.5">
        <time dateTime={post.date} className={`${LABEL} tabular-nums`}>
          {formatDay(post.date)}
        </time>
        <h3 className="text-ui-md font-medium text-pretty text-text">
          <a href={to(`/journal/${post.slug}`)} className="focus-ring rounded-control-xs after:absolute after:inset-0 after:rounded-panel">
            {post.title}
          </a>
        </h3>
      </div>
    </article>
  );
}
```
