---
title: "Customizing the presentation"
description: "The three tiers: config, TSX islands and optimized media, plus the frontend exports."
requested_language: cs
language: en
translation_notice: "This page isn't translated yet"
url: https://docs.systhema.app/cs/next/payload/posts/presentation
version: unreleased (main)
docs_index: https://docs.systhema.app/cs/next/llms.txt
---
> This page isn't translated yet. Showing English.


Every visible piece of the posts UI — each listing card, the highlighted lead card, and the single-post hero — is independently customizable along a **three-tier spectrum**. The same module serves a plain blog and a heavily art-directed magazine without forking components.

## The three tiers (and how they win)

| Tier                 | What it is                                                                                     | How you reach it                                      | Code? |
| -------------------- | ---------------------------------------------------------------------------------------------- | ----------------------------------------------------- | ----- |
| **Tier 0 — Default** | Figma-matching components (`PostCard`, `HighlightCard`, the default hero).                     | Nothing — it's what renders out of the box.           | None  |
| **Tier 1 — Config**  | Per unit: toggle which parts render (`show`) + override the class of each part (`classNames`). | A plain object in `posts.listing` / `posts.hero`.     | None  |
| **Tier 2 — Island**  | Replace the whole unit with your own TSX component.                                            | An `island` (or `templateSlots`) component reference. | Yes   |

**Precedence is `Tier 2 → Tier 1 → Tier 0`.** Per unit the three tiers collapse into one shape — `{ show?, classNames?, island? }`:

- If you set an **`island`**, it owns rendering. The Tier-1 `show`/`classNames` are **not** auto-applied (the module can't reach inside your TSX) — but the resolved config still travels on the `PostView` the island receives (`post.config`), so an island MAY honor your toggles if it wants to.
- If you set only `show` / `classNames` (Tier 1), the **default** component renders and applies them.
- If you set nothing, the **default** renders with the Figma defaults (Tier 0).

> [!TIP]
> **Mental model.** Think of each unit as a slot with a beautiful default in it. Tier 1 lets you trim and re-skin that default with data. Tier 2 lets you rip the default out and drop your own component into the slot — at which point you own everything inside it.

## Base styling (the `blocks.posts` CSS gate)

The Tier-0 components ship their visual styling as a design-system CSS layer in `@systhemaui/core` (the `.post-card*`, `.post-highlight*`, `.post-meta*`, `.posts-list*`, `.archive-filter*`, and `.post-share*` hooks). The archive filter is composed from design-system primitives — a [`SearchField`](https://docs.systhema.app/cs/next/components/search-field.md) for search, `FormSelect` dropdowns per taxonomy, and removable active-filter chips — so it reuses the `form/inputBlock` + `chip` token surfaces and tracks your color system automatically.

This layer is gated behind `blocks.posts` in your `systhema.config.ts` and is **on by default** (like every other component block). To opt out — e.g. you fully art-direct the posts UI with Tier-2 islands and want none of the defaults — set:

```ts title="systhema.config.ts"
blocks: { posts: false }
```

**Per-card scroll reveal.** Each `PostCard` / `HighlightCard` carries the config `animationClasses` so posts reveal **individually** as they scroll into view, rather than the whole listing fading in at once. Cards in the same grid **row** cascade left to right, one `var(--tw-stagger-delay, 120ms)` step per column (below `md` every grid is a single column and no delay applies; the single-column `row` view is never staggered). Set `--tw-stagger-delay` to retune it — that is the same custom property the `stagger-*` utility reads. Disable per card with the `disableAnimation` prop (also accepted by `PostsList`, which threads it to every card).

> [!IMPORTANT]
> The listing root is a `<Stack allowChildAnimation>`. A plain `<Stack>` cancels every scroll reveal below it — that is right for a laid-out group and wrong for a list of independent cards. If you build a **custom** listing wrapper out of `<Stack>`, pass `allowChildAnimation` (or add the `aos-allow-children` class) or your cards will render fully visible with no reveal at all.

**Colour.** The default card follows the active color system: the title carries `color-heading`, the excerpt `color-body`, and the card root is anchored to `--color-typography-body` so the kicker and byline (both `currentColor`-derived) mute against the right base. Drop a listing into a themed `<Section>` and its text follows that theme; a `classNames.title` / `classNames.excerpt` Tailwind text colour still wins.

## Tier 1 — config (no code)

Configure presentation in the `posts` plugin option. Three namespaces — `listing` (the cards), `hero` (the single-post banner), and `post` (single-post layout knobs):

```ts title="src/payload.config.ts"
posts: {
  enabled: true,
  listing: {
    cards: {
      grid2: { show, classNames, island },   // 2-column grid card
      grid3: { show, classNames, island },   // 3-column grid card (the default view)
      grid4: { show, classNames, island },   // 4-column grid card
      row:   { show, classNames, island },   // horizontal "row" card
    },
    highlightCard: { show, classNames, island }, // the full-bleed lead card
  },
  hero: { show, classNames, island },         // the single-post hero
  post: { readingWidth, bylineAvatarSize },   // single-post layout knobs (see below)
  templateSlots: { beforeContent, afterContent }, // Tier-2 only (see below)
}
```

**`show` — toggle which parts render.** A part is ON unless you set it to `false`, so `show` is purely subtractive/additive over the documented defaults. The exact part keys per unit:

| Unit                                     | Part keys (`show` / `classNames`)                                             | Default-ON                                          | Default-OFF                                           |
| ---------------------------------------- | ----------------------------------------------------------------------------- | --------------------------------------------------- | ----------------------------------------------------- |
| **card** (`grid2`/`grid3`/`grid4`/`row`) | `media`, `label`, `title`, `author`, `date`, `excerpt`, `readMore`            | media, label, title, author, date, excerpt          | `readMore` (the whole card is the link)               |
| **highlightCard**                        | `media`, `overlay`, `label`, `title`, `author`, `date`, `excerpt`, `readMore` | media, overlay, label, title, author, date, excerpt | `readMore`                                            |
| **hero**                                 | `media`, `kicker`, `label`, `title`, `description`, `author`, `date`, `tags`  | media, kicker, label, title, description, tags      | `author` / `date` follow General Settings (see below) |

> [!NOTE]
> **Hero author/date.** The hero byline shows only when General Settings → Posts allows it **and** the hero `show` allows it (both must be true). An absent `show.author`/`show.date` defaults to ON, so General Settings stays in control. The other hero parts (`label`/`title`/`description`/`media`/`kicker`/`tags`) default ON and a `show` can hide them.

**Hero kicker + tags (taxonomy).** The default post hero renders the post's **category** as a muted eyebrow kicker above the title (the `.post-type.text-label` treatment), and its **tags** as a `<Chip>` row alongside the byline — matching a standard article hero. Both default ON; turn either off with `hero: { show: { kicker: false } }` / `{ show: { tags: false } }`, and restyle via `hero: { classNames: { kicker, tags } }`. General Settings has no separate category/tags toggle — the hero `show` is the off-switch. The kicker text + tag labels come from the same shared resolvers the listing cards use, so they read identically.

**`classNames` — override the class of any part.** Keys are the same part keys plus `root` (the unit's outer element); the highlight card also has `overlay` (the dark scrim). Each override is folded over the component's own default with **twMerge**, so **your class wins on conflicting utilities** instead of stacking:

```ts
grid3: { classNames: { media: 'aspect-square' } }
// the default `aspect-[4/3]` is DROPPED — twMerge resolves the conflict in your favour.
```

**Per-layout cards + the aspect defaults.** The four card layouts (`grid2`/`grid3`/`grid4`/`row`) are configured (and islanded) **independently** — the active archive/listing `view` selects which one renders. **Mobile is _not_ a config target:** it's the module's responsive transform of whichever desktop layout is active — **always stacked (media over content), 4:3**. The default media aspect per layout (the default value of `classNames.media`, overridable):

| Layout  | Mobile | Desktop                                   |
| ------- | ------ | ----------------------------------------- |
| `grid2` | 4:3    | **16:9** (`aspect-[4/3] md:aspect-video`) |
| `grid3` | 4:3    | 4:3 (`aspect-[4/3]`)                      |
| `grid4` | 4:3    | 4:3                                       |
| `row`   | 4:3    | **3:2** (`aspect-[4/3] md:aspect-[3/2]`)  |

For the four **card** layouts the aspect is the default value of `classNames.media`, so a Tier-1 `classNames.media` override replaces it (twMerge). The **`highlightCard`** is **16:9 desktop, 4:3 mobile**, but its aspect lives on the card **root** (the media fills the card), so override `classNames.root` — **not** `classNames.media` — to change it. In both cases, overriding with a single `aspect-square` collapses the base aspect but **not** a `md:` variant — to also change desktop, set the `md:` utility too (e.g. `'aspect-square md:aspect-square'`).

**Copy-paste example** — a 3-column grid with no excerpt, square media, and a smaller title:

```ts title="src/payload.config.ts"
posts: {
  enabled: true,
  listing: {
    cards: {
      grid3: {
        show: { excerpt: false },                 // hide the excerpt
        classNames: {
          media: 'aspect-square md:aspect-square', // 1:1 at every breakpoint
          title: 'text-h5',                        // smaller heading
        },
      },
    },
  },
}
```

### Single-post layout (`post`)

The `post` namespace holds two single-post **layout measurements** — the kind you'd otherwise hand-tune with a CSS override on every project. Both are plain CSS lengths and **default to the module's current layout**, so omitting them changes nothing (zero regression); set either to retune just that one measurement.

| Knob               | What it sets                                                                                  | Default                                                                                                                      |
| ------------------ | --------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------- |
| `readingWidth`     | The post body's reading-column `max-width` — the measure the lexical body is centered within. | The article's container-derived width (`container-width − 2·article-padding-x`) — a full reading column, no extra narrowing. |
| `bylineAvatarSize` | The byline avatar size (applied to both its width and height).                                | `1.75em` (sized relative to the byline text).                                                                                |

```ts title="src/payload.config.ts"
posts: {
  enabled: true,
  post: {
    readingWidth: '773px', // narrow the body to a comfortable reading measure
    bylineAvatarSize: '64px', // a larger byline avatar
  },
}
```

Both apply as CSS custom properties (`--post-reading-width` / `--post-byline-avatar-size`) on the single-post shell (`#post`), consumed by the `blocks.posts` core CSS with the defaults above as fallbacks. The reading-width constrains the same reading-flow elements the `<Article>` already centers (paragraphs, headings, lists, inline figures) — full-bleed sections, `figure-w-full` / `-screen` / `-container` media, and cards/columns/stacks stay edge-to-edge. Accepts any CSS length (`'48rem'`, `'70ch'`, `'4rem'`, …). A custom `hero` island or `templateSlots` is unaffected by `readingWidth` (it renders its own layout); the byline avatar size applies wherever the default byline renders.

## Tier 2 — full TSX islands

When config isn't enough, replace the whole unit with your own component via `island` (cards/highlight/hero) or `templateSlots` (around the post body). Your component receives the resolved [`PostView`](#the-postview-an-island-receives) and renders anything.

**Registering an island** — set it on the matching unit (component _references_, not closures):

```tsx title="src/payload.config.ts"
import { withSysthema } from '@systhemaui/payload'
import MagazineCard from './posts/MagazineCard'       // 'use client'
import PromoHero from './posts/PromoHero'             // server or client
import RelatedDisclaimer from './posts/RelatedDisclaimer'

export default buildConfig(
  withSysthema(
    {
      posts: {
        enabled: true,
        listing: {
          cards: { grid3: { island: MagazineCard } }, // replace the 3-col card
          highlightCard: { island: MagazineCard },     // (a highlight island fits here too)
        },
        hero: { island: PromoHero },                    // replace the single-post hero
        templateSlots: { afterContent: RelatedDisclaimer }, // inject after the body
      },
    },
    baseConfig,
  ),
)
```

### The `'use client'` rule

> [!IMPORTANT]
> **Card and highlight islands MUST be `'use client'` components.** Hero and `templateSlots` islands may be **server or client** components.

Why: the listing (`PostsList`) is a client component that augments the server-rendered first page (load-more, infinite scroll, pagination). Card/highlight islands are therefore threaded across the **RSC boundary** as **component references** — and only a `'use client'` reference survives that server→client hand-off. A server closure passed down would throw _"Functions cannot be passed directly to Client Components."_ The hero and template slots, by contrast, render **inline in the server post template**, so they never cross the boundary and have no such constraint.

### The `PostView` an island receives

Every island (and every default component) receives the same resolved, **serializable** view-model as a single `post` prop. It's mapped server-side from a populated Post document (keeping the no-email public-byline policy and the permalink engine), so it crosses to client cards as plain data — no functions, no class instances.

```ts
interface PostView {
  id: string | number
  title: string
  href: string // resolved permalink
  excerpt?: string
  label?: string // the category name shown as the card/hero label ("CATEGORY 01")
  category?: { name: string; slug: string } | null
  tags: { name: string; slug: string }[]
  postType: string // e.g. 'article', 'video'
  author?: { name: string; avatar?: { url: string; alt?: string }; role?: string } | null // public byline; `role` is the optional byline secondary label
  publishedAt?: string // ISO; format it however you like
  media?: {
    url: string
    alt?: string
    width?: number
    height?: number
    srcset?: string
    isPlaceholder?: boolean // true when `url` is the configured placeholder, not the post's own image
  } | null
  layout?: 'grid2' | 'grid3' | 'grid4' | 'row' // present for cards; lets one island branch per layout. Absent on the hero.
  config?: { show?: Record<string, boolean>; classNames?: Record<string, string> } // the resolved Tier-1 config (an island MAY consult it)
  doc: any // the full populated post document — typed `any` (react has no Payload dep); cast to your generated `Post` in your island. The escape hatch: read any field the view doesn't surface.
}
```

`media` is `null` when the post has no image and no placeholder applies. When the requirement is `required`/`recommended` with a placeholder configured, `media` carries the placeholder marked `isPlaceholder: true` (see [Featured media](https://docs.systhema.app/cs/next/payload/posts.md#featured-media)). The `doc` field is the **escape hatch**: read any custom field the resolved view doesn't surface.

`author.role` is the byline **secondary label** (e.g. "Editor", "Staff Writer"). It is derived **publicly** (under `overrideAccess`, so anonymous published reads still get it) from the first present, non-empty **string** among the author user's fields, in priority order `position` → `role` → `jobTitle` → `title`. The key is **omitted** when the user has none, so projects without such a field are unaffected. (The access-control capability `roles` array is deliberately not used — it's an access concept, not a public job title.) The default Single-Post template (`DefaultPost`) renders `role` after the author name inside the byline as `<span className="post-meta-author-role">`; islands and custom bylines can read `view.author?.role` and render it however they like.

### A complete `'use client'` card island

```tsx title="src/posts/MagazineCard.tsx"
'use client'

import React from 'react'
import Link from 'next/link'
import Image from 'next/image'
import type { PostView } from '@systhemaui/react'
import { postByline } from '@systhemaui/react' // optional: the same byline helper the default uses

export default function MagazineCard({ post }: { post: PostView }) {
  const { author, date, dateTime } = postByline(post)

  // `post.layout` lets one island branch per layout (e.g. wider media on `row`).
  const wide = post.layout === 'row'

  return (
    <Link href={post.href} className="magazine-card group block">
      {post.media && (
        <div className={wide ? 'aspect-video' : 'aspect-square'}>
          <Image
            src={post.media.url}
            alt={post.media.alt ?? ''}
            fill
            sizes="(max-width: 768px) 100vw, 33vw"
            className="object-cover"
          />
        </div>
      )}
      {post.label && <span className="text-label">{post.label}</span>}
      <h3 className="text-h5 group-hover:underline">{post.title}</h3>
      {(author || date) && (
        <p className="text-label">
          {author}
          {author && date ? ' • ' : ''}
          {date && <time dateTime={dateTime}>{date}</time>}
        </p>
      )}
    </Link>
  )
}
```

The hero and `templateSlots` components have the same `{ post: PostView }` signature; they just don't need the `'use client'` directive (though it's harmless if you add it).

> [!TIP]
> **Reproducing the defaults.** `@systhemaui/react` exports the same primitives the default cards use, so an island can match Tier-0 behaviour exactly: `showPart` (the subtractive `show` resolver), `resolvePartClassName` (twMerge override-wins), `defaultMediaAspect` (per-layout aspect), `sizesForLayout` (the layout-aware `next/image` `sizes` hint), `postByline` / `formatPostDate`, and the layout-aware `PostCard` / `HighlightCard` / `PostMeta` components themselves. See [React: Posts module](https://docs.systhema.app/cs/next/components/posts-helpers.md).

## Optimized media: the next `PostsList` wrapper

`@systhemaui/react`'s `PostsList` is media-agnostic — given no render-prop it renders a plain `<img>`. `@systhemaui/next` ships a thin **`PostsList` wrapper** (not a bare re-export) that **defaults** `renderImage` / `renderHighlightImage` to `next/image`, so every listing (Latest Posts, Archive, Related Posts, the `posts` block) ships responsive, optimized media without you wiring `next/image` by hand:

- **Grid/row cards** render via `next/image` (`fill`) with a `sizes` hint derived from the listing `view` through `sizesForLayout(view)` — so each column count downloads the right `srcset` candidate (`grid4` → `25vw`, `grid3` → `33vw`, `grid2`/`row` → `50vw`, all `100vw` on mobile) instead of over/under-fetching with one hardcoded hint.
- **The highlight (lead) card** renders full-bleed (`sizes="100vw"`, `priority`) — it's the LCP candidate at the top of the listing.
- A consumer-supplied `renderImage` / `renderHighlightImage` always **wins**; the `next/image` defaults only fill the gaps. The whole prop surface (including `dividers`) is forwarded unchanged, so it's a drop-in for the react `PostsList`.

The built-in templates (`DefaultArchive`, `DefaultPost`'s related grid, the `posts` block) already render through this next-optimized `PostsList`, so the optimized media is the default everywhere out of the box. `sizesForLayout` is exported from both `@systhemaui/react` and `@systhemaui/next` for islands that render their own `next/image`.

## Stores and exported types

Island refs live in the separate `postsPresentationStore` (`setPostsPresentation` / `getPostsPresentationConfig` / `getPostsPresentationIslands`) so they never serialize — mirroring `customBlocksStore`. The featured-media model (`posts.media: { requirement?, placeholder? }`) lives in the store `postsMediaStore` (`setPostsMediaConfig` / `getPostsMediaConfig`).

Re-exported presentation/media types: `PostsOptions`, `PostsPresentationConfig`, `PostsPresentationIslands`, `PostCardLayout`, `SerializableCardConfig`, `SerializableHighlightCardConfig`, `SerializablePostHeroConfig`, `SerializablePostLayoutConfig`, `PostsMediaConfig`, `PostsMediaRequirement`, plus the consumer-facing config shapes `CardConfig` / `HighlightCardConfig` / `PostHeroConfig` / `PostLayoutConfig` (from `@systhemaui/payload`); the view-model + island component types (`PostView`, `PostLayout`, `ResolvedUnitConfig`, `PostCardComponent`, `PostHeroComponent`, `PostSlotComponent`) re-export from `@systhemaui/payload/next` (originally `@systhemaui/react`).

## Frontend exports

The `@systhemaui/payload/next` entry exports the server-side posts surface — the archive filter pipeline (`resolveArchiveData`, `PostsListClient`, `ArchiveFilterProvider` / `useArchiveFilters`, `ArchiveFilterControl`), `mapPostToView` (Post document → serializable `PostView`), the built-in `DefaultArchive` / `DefaultPost` templates, and `resolveRelatedPosts`. See [Building a custom archive](https://docs.systhema.app/cs/next/payload/posts/archives.md#building-a-custom-archive-search--load-more--ads-together) for the composition recipe.

```ts
import {
  resolveArchiveData,
  resolvePostsPage,
  resolveRelatedPosts,
  PostsListClient,
  ArchiveFilterProvider,
  useArchiveFilters,
  ArchiveFilterControl,
  mapPostToView,
  getPostTypeLabels,
  resolvePostAuthor,
  resolvePostAuthors,
  authorRelationId,
  DefaultArchive,
  DefaultPost,
  type ArchiveData,
  type ArchiveFilterControlProps,
  type PostsListClientProps,
  type PostsRestFilters,
  type PostsPageRequest,
  type PostsPageResult,
  type ResolvedPostAuthor,
  type AuthorRelation,
  type PostView,
  type PostLayout,
  type PostTypeLabels,
} from '@systhemaui/payload/next'
```

- **`resolveArchiveData`** (→ `ArchiveData`) — resolves the full server-side archive listing from the page's `listing` config: queries page 1 (or maps the `fixed` picks), batch-resolves bylines (`overrideAccess`), maps each doc to a `PostView`, loads the full taxonomy pill options, resolves the section presentation (`theme` / `layoutBg` / `padding`) and the `gap` token (index → `GapSize`), normalizes the hero's `filterStyle` (`dropdown` / `chips`) onto `ArchiveData.filterStyle`, and threads the resolved Tier-1 config + Tier-2 island refs + media model down. `DefaultArchive` narrows the pill options to the hero's enabled `filters` before handing them to the control. Filtering is **client-driven** via the `ArchiveSearchFilter` callback (catch-all archive routes are statically cached, so the resolver never reads `searchParams`).
- **`PostsListClient`** (`PostsListClientProps`, `PostsRestFilters`) — the client listing that powers Latest / Archive / Related / the posts block. Renders the lead-grid + tail-list split, drives `loading` (`none` / `pagination` / `loadMore` / `infinite`), and posts to the `/sys/posts-list` route for filtered/appended pages. Wrap it in `ArchiveFilterProvider` and pass `injectItems` to interleave ads at the grid→list seam.
- **`ArchiveFilterControl`** (`ArchiveFilterControlProps`) — the search + taxonomy-filter control bound to the filter context. Forwards `order?: readonly ArchiveFilterDimension[]`, `filterStyle?: ArchiveFilterStyle`, `searchPlaceholder?: string`, and `filtersLabel?: string` to the underlying [`ArchiveSearchFilter`](https://docs.systhema.app/cs/next/components/archive-search-filter.md): `order` reorders the taxonomy filters (default category → tag → type; omitted dimensions keep their default position after the listed ones), `filterStyle` chooses `'dropdown'` (default) vs a `'chips'` toggle row, `searchPlaceholder` overrides the search input placeholder (default `'Search…'`), and `filtersLabel` renders an optional leading eyebrow as `.archive-filter-label` (omitted when unset).
- **`resolvePostsPage`** (`PostsPageRequest` → `PostsPageResult`) — the shared "filters → `PostView` page" resolver behind both SSR page 1 and the load-more / filter fetch. Runs `queryPosts` + the **batched, `overrideAccess`** author resolution + `mapPostToView`, so a load-more page keeps its byline (name + avatar) where a client map of the access-gated `/api/posts` response would drop it. Backs the module's `/sys/posts-list` route (`handlePostsList`, mounted via `systhemaApiRoutes`); `PostsListClient` posts to it automatically and falls back to `/api/posts` when it's not mounted.
- **`resolvePostAuthor`** (single, React-`cache`d) / **`resolvePostAuthors`** (batched, no N+1 — for listings) — resolve a post's public byline as a `ResolvedPostAuthor` for custom bylines. They run under `overrideAccess`, so anonymous published reads still get the byline. `authorRelationId` pulls the author relation id; `AuthorRelation` types the raw relation.

  ```ts
  interface ResolvedPostAuthor {
    name: string
    avatar?: { url: string; alt?: string | null } | null
    role?: string | null
  }
  ```

  `role` is the byline **secondary label** (e.g. "Editor", "Staff Writer"), derived publicly from the first present, non-empty string among the author user's fields, in priority order `position` → `role` → `jobTitle` → `title`. Absent when the user has none, so projects without such a field are unaffected. (The access-control capability `roles` array is deliberately not used — it's an access concept, not a public job title.) See [Posts → the byline secondary label](#the-postview-an-island-receives).
