Docs

This page isn't translated yet

Customizing the presentation

The three tiers: config, TSX islands and optimized media, plus the frontend exports.

On this page

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)Link to this section

TierWhat it isHow you reach itCode?
Tier 0 — DefaultFigma-matching components (PostCard, HighlightCard, the default hero).Nothing — it's what renders out of the box.None
Tier 1 — ConfigPer unit: toggle which parts render (show) + override the class of each part (classNames).A plain object in posts.listing / posts.hero.None
Tier 2 — IslandReplace 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).

Base styling (the blocks.posts CSS gate)Link to this section

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

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

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)Link to this section

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

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:

UnitPart keys (show / classNames)Default-ONDefault-OFF
card (grid2/grid3/grid4/row)media, label, title, author, date, excerpt, readMoremedia, label, title, author, date, excerptreadMore (the whole card is the link)
highlightCardmedia, overlay, label, title, author, date, excerpt, readMoremedia, overlay, label, title, author, date, excerptreadMore
heromedia, kicker, label, title, description, author, date, tagsmedia, kicker, label, title, description, tagsauthor / date follow General Settings (see below)

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:

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):

LayoutMobileDesktop
grid24:316:9 (aspect-[4/3] md:aspect-video)
grid34:34:3 (aspect-[4/3])
grid44:34:3
row4:33: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:

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)Link to this section

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.

KnobWhat it setsDefault
readingWidthThe 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.
bylineAvatarSizeThe byline avatar size (applied to both its width and height).1.75em (sized relative to the byline text).
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 islandsLink to this section

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 and renders anything.

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

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' ruleLink to this section

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 receivesLink to this section

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.

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). 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 islandLink to this section

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

Optimized media: the next PostsList wrapperLink to this section

@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 typesLink to this section

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 exportsLink to this section

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 for the composition recipe.

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: 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-cached) / 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.

    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.