PostsList
The unified posts listing: grid, row and lead layouts, paging and injected items.
On this page
PostsList renders a list of posts as a grid or a row list, with an optional full-bleed highlight card, a lead grid, paging and injected items such as ads. Latest Posts, archive listings, related posts and the Payload posts block all render through it. It takes plain PostView objects, so it works with any data source, not only Payload.
import { PostsList, type PostView } from '@systhemaui/next'
function post(id: number, title: string, label: string, image: string): PostView {
return {
id,
title,
label,
href: '#',
excerpt: 'How a small team keeps a large site consistent without slowing its editors down.',
tags: [],
postType: 'article',
author: { name: 'Mira Kovač' },
publishedAt: `2026-09-${10 + id}T09:00:00.000Z`,
media: { url: `/demo-assets/${image}`, alt: '', width: 1800, height: 1200 },
doc: null,
}
}
const posts = [
post(4, 'Designing with tokens from day one', 'Design', 'city.webp'),
post(3, 'A calmer content model for editors', 'Editorial', 'workspace.webp'),
post(2, 'Shipping a multilingual site in a week', 'Engineering', 'mountains.webp'),
post(1, 'A field guide to spacing', 'Design', 'coast.webp'),
]
export default function Demo() {
return <PostsList items={posts} view="grid3" highlightFirst />
}ImportLink to this section
import { PostsList } from '@systhemaui/next'In a React app without Next.js, import it from @systhemaui/react. The Next.js version renders media with next/image and links with next/link; see Next.js.
LayoutsLink to this section
view sets the layout of the list: grid2, grid3, grid4 (the default) or row. Every grid is one column below md. grid2 turns two-up from md; grid3 and grid4 are two-up from md and three-up or four-up from lg. Each card gets the matching layout, which sets its media aspect ratio and which parts it shows (see PostCard).
GridLink to this section
import { PostsList, type PostView } from '@systhemaui/next'
function post(id: number, title: string, label: string, image: string): PostView {
return {
id,
title,
label,
href: '#',
excerpt: 'How a small team keeps a large site consistent without slowing its editors down.',
tags: [],
postType: 'article',
author: { name: 'Mira Kovač' },
publishedAt: `2026-09-${10 + id}T09:00:00.000Z`,
media: { url: `/demo-assets/${image}`, alt: '', width: 1800, height: 1200 },
doc: null,
}
}
const posts = [
post(3, 'A calmer content model for editors', 'Editorial', 'workspace.webp'),
post(2, 'Shipping a multilingual site in a week', 'Engineering', 'city.webp'),
post(1, 'A field guide to spacing', 'Design', 'coast.webp'),
]
export default function Demo() {
return <PostsList items={posts} view="grid3" />
}Row list with dividersLink to this section
row puts the media beside the text from md up and stacks it below. Add dividers to draw a 1px --color-separator-line hairline between rows. Grids ignore dividers.
import { PostsList, type PostView } from '@systhemaui/next'
function post(id: number, title: string, label: string, image: string): PostView {
return {
id,
title,
label,
href: '#',
tags: [],
postType: 'article',
author: { name: 'Mira Kovač' },
publishedAt: `2026-09-${10 + id}T09:00:00.000Z`,
media: { url: `/demo-assets/${image}`, alt: '', width: 1800, height: 1200 },
doc: null,
}
}
const posts = [
post(3, 'A calmer content model for editors', 'Editorial', 'workspace.webp'),
post(2, 'Shipping a multilingual site in a week', 'Engineering', 'city.webp'),
post(1, 'A field guide to spacing', 'Design', 'coast.webp'),
]
export default function Demo() {
return <PostsList items={posts} view="row" dividers />
}Lead grid and tail listLink to this section
leadCount renders the first N cards in a separate lead grid, laid out by leadView (default grid3), and the rest below it in view. With highlightFirst, the count starts after the highlight card. A leadCount of 0, or one at least as large as the number of cards, renders a single uniform list.
import { PostsList, type PostView } from '@systhemaui/next'
function post(id: number, title: string, label: string, image: string): PostView {
return {
id,
title,
label,
href: '#',
excerpt: 'How a small team keeps a large site consistent without slowing its editors down.',
tags: [],
postType: 'article',
author: { name: 'Mira Kovač' },
publishedAt: `2026-09-${10 + id}T09:00:00.000Z`,
media: { url: `/demo-assets/${image}`, alt: '', width: 1800, height: 1200 },
doc: null,
}
}
const posts = [
post(5, 'Designing with tokens from day one', 'Design', 'city.webp'),
post(4, 'A calmer content model for editors', 'Editorial', 'workspace.webp'),
post(3, 'Shipping a multilingual site in a week', 'Engineering', 'mountains.webp'),
post(2, 'A field guide to spacing', 'Design', 'coast.webp'),
post(1, 'Choosing a type pairing', 'Design', 'abstract-square.webp'),
]
export default function Demo() {
return (
<PostsList items={posts} highlightFirst leadCount={2} leadView="grid2" view="row" dividers />
)
}ExamplesLink to this section
GapsLink to this section
Grids default to a sm column gap and a md row gap, and the row list to a md row gap. gapX and gapY override one axis each with a gap token (none, xs, sm, md, lg, xl, 2xl, 3xl), applied inline as column-gap: var(--gap-<size>) and row-gap: var(--gap-<size>).
<PostsList items={posts} view="grid4" gapX="md" gapY="lg" />Card optionsLink to this section
cardConfig is passed to every default card: show turns parts on or off, classNames overrides a part's classes, and aspectRatio, mediaWidth, bodyAlign and mediaGap set inline presentation values. highlightConfig does the same for the highlight card, and leadCardConfig for the lead grid (it falls back to cardConfig). The parts are listed on PostCard.
import { PostsList, type PostView } from '@systhemaui/next'
function post(id: number, title: string, label: string, image: string): PostView {
return {
id,
title,
label,
href: '#',
excerpt: 'How a small team keeps a large site consistent without slowing its editors down.',
tags: [],
postType: 'article',
author: { name: 'Mira Kovač' },
publishedAt: `2026-09-${10 + id}T09:00:00.000Z`,
media: { url: `/demo-assets/${image}`, alt: '', width: 1800, height: 1200 },
doc: null,
}
}
const posts = [
post(3, 'A calmer content model for editors', 'Editorial', 'workspace.webp'),
post(2, 'Shipping a multilingual site in a week', 'Engineering', 'city.webp'),
post(1, 'A field guide to spacing', 'Design', 'coast.webp'),
]
export default function Demo() {
return (
<PostsList
items={posts}
view="grid3"
cardConfig={{ show: { excerpt: false, readMore: true }, aspectRatio: '1/1' }}
/>
)
}Loading more postsLink to this section
items is the first page. To load more, set loading and pass fetchMore(page), which returns the next page's PostView[], together with paging ({ total, page, limit }) so the list knows when it has everything:
loading | Behaviour |
|---|---|
none | Renders items only (default). |
loadMore | A Load more button appends the next page. |
infinite | The next page is appended when a sentinel below the list scrolls within 200px of the viewport. |
pagination | Previous and next buttons with a page / total counter replace the rendered page. |
PostsList never fetches on its own, so fetchMore is where you call your API. A function prop makes the calling module a client module.
'use client'
import { PostsList, type PostView } from '@systhemaui/next'
function post(id: number, title: string, image: string): PostView {
return {
id,
title,
label: 'Journal',
href: '#',
tags: [],
postType: 'article',
author: { name: 'Mira Kovač' },
publishedAt: `2026-09-${10 + id}T09:00:00.000Z`,
media: { url: `/demo-assets/${image}`, alt: '', width: 1800, height: 1200 },
doc: null,
}
}
const all = [
post(6, 'Designing with tokens from day one', 'mountains.webp'),
post(5, 'A calmer content model for editors', 'workspace.webp'),
post(4, 'Shipping a multilingual site in a week', 'city.webp'),
post(3, 'A field guide to spacing', 'coast.webp'),
post(2, 'Choosing a type pairing', 'abstract-square.webp'),
post(1, 'Photographing for the web', 'forest-portrait.webp'),
]
async function fetchPage(page: number) {
await new Promise((resolve) => setTimeout(resolve, 600))
return all.slice((page - 1) * 3, page * 3)
}
export default function Demo() {
return (
<PostsList
items={all.slice(0, 3)}
view="grid3"
loading="loadMore"
fetchMore={fetchPage}
paging={{ total: all.length, page: 1, limit: 3 }}
/>
)
}loadMoreButton styles the button: variant (a button variant, the default variant when unset), title, iconBeforeHtml / iconAfterHtml (icon markup strings) and align (left, center or right). In pagination mode the highlight and the lead grid appear on page 1 only; prefer loadMore or infinite for a lead/tail listing, because they append to the tail.
With a highlight or a lead grid, page 1 holds limit cards plus those extra ones and later pages hold limit. The listing helpers compute the offsets your fetchMore needs.
Injecting itemsLink to this section
injectItems is called after every card with { index, total, section, isLeadEnd }; return a node to render it after that card, or null. In a split listing it is called once more with isLeadEnd: true between the lead grid and the tail, and that node renders full width in a .posts-list-interspace wrapper, outside both containers.
<PostsList
items={posts}
leadCount={6}
view="row"
injectItems={({ index, isLeadEnd }) =>
isLeadEnd ? <AdBanner /> : index > 0 && index % 8 === 0 ? <AdTile /> : null
}
/>Custom cardsLink to this section
CardIsland and HighlightIsland replace the default cards with your own component, which receives { post }. They must be client components ('use client'), and they handle their own linking. See Customizing the presentation.
ThemeLink to this section
theme sets data-theme on the list, so the cards resolve against that color-system mode. See Themes and backgrounds.
PropsLink to this section
| Prop | Type | Default | Description |
|---|---|---|---|
items (required) | PostView[] | - | Server-rendered first page of resolved post view-models. |
view | PostsView | 'grid4' | |
highlightFirst | boolean | false | When true, the first item renders as a full-bleed highlighted card (large media + bottom-left overlay) and the remainder flow into the grid. |
leadCount | number | - | Optional lead/tail split (the Figma archive shape). When set to a positive number, the first leadCount post-highlight cards render in the leadView grid (the lead), and the remainder render below in the primary view container (the tail). Load-more appends to the tail. Unset / 0 / >= the item count ⇒ no split, the uniform single-container behaviour. Counts cards AFTER any highlightFirst hero — so highlightFirst + a leadCount of 6 renders: 1 hero, then a 6-card lead grid, then the tail. |
leadView | PostsView | 'grid3' | Lead grid layout when leadCount splits the listing (only when leadCount > 0). |
loading | PostsLoading | 'none' | |
fetchMore | FetchMorePosts | - | Required for pagination/loadMore/infinite to fetch subsequent pages. |
paging | PostsPaging | - | |
injectItems | ((context: { index: number; total: number; section: 'lead' | 'tail'; isLeadEnd: boolean; }) => ReactNode) | - | Interleave extra items (e.g. ads) into the rendered list. Called per render index; return a node to inject AFTER the card at that index, or null. Lets a consumer drop a leaderboard every N cards without PostsList knowing about ad shapes. section is 'lead' or 'tail' (always 'lead' when the listing isn't split). isLeadEnd is true ONLY for the single seam call fired BETWEEN the lead grid and the tail list (when a split is active) — so a consumer can drop a full-width leaderboard at the grid→list seam with ({ section, isLeadEnd }) => section === 'lead' && isLeadEnd ? ad : null, and it renders as a sibling between the two containers (never inside the grid, which would constrain it to a single column). |
className | string | - | |
gapX | GapSize (lg, md, sm, none, xs, xl, 2xl, 3xl) | - | Optional per-axis inter-card gap (a design-system gap token size, none … 3xl). gapX sets the column-gap, gapY the row-gap, applied as inline styles on the grid/row container — overriding the design-default gaps (the core-CSS asymmetric grid gaps, or the .posts-list-row row gap) on that axis only. Omit either to keep that axis's default. |
gapY | GapSize (lg, md, sm, none, xs, xl, 2xl, 3xl) | - | |
theme | ColorSystem (dark, default) | - | |
renderImage | ((media: { url: string; alt?: string | undefined; width?: number | undefined; height?: number | undefined; srcset?: string | undefined; isPlaceholder?: boolean | undefined; }) => ReactNode) | - | Forwarded to each PostCard (the next package passes a next/image renderer). |
renderHighlightImage | ((media: { url: string; alt?: string | undefined; width?: number | undefined; height?: number | undefined; srcset?: string | undefined; isPlaceholder?: boolean | undefined; }) => ReactNode) | - | Forwarded to the HighlightCard (next passes a next/image fill renderer). |
cardConfig | ResolvedCardConfig | - | Resolved Tier-1 card config for the main/tail (view) layout (show/classNames). |
leadCardConfig | ResolvedCardConfig | - | Resolved Tier-1 card config for the LEAD (leadView) layout when a lead/tail split is active. Falls back to {@link cardConfig} when unset, so an un-split listing (or a caller that doesn't distinguish sections) is unaffected. |
highlightConfig | ResolvedHighlightCardConfig | - | Resolved Tier-1 highlight-card config (show/classNames incl. overlay). |
loadMoreButton | { variant?, title?, iconBeforeHtml?, iconAfterHtml?, align? } | - | Optional configuration for the Load more button (only used when loading === 'loadMore'). The button renders as a real design-system <Button> carrying the chosen variant (unset ⇒ the design-system default variant). title overrides the "Load more" label; iconBeforeHtml / iconAfterHtml are pre-resolved icon HTML strings injected via dangerouslySetInnerHTML (the payload layer resolves icon picks to HTML); align positions the actions row (left / center / right, default center). The action itself (fetch next page) is unchanged. |
CardIsland | PostCardIsland | - | Tier-2 card island ('use client'). When set it replaces the default card. |
HighlightIsland | PostCardIsland | - | Tier-2 highlight island ('use client'). When set it replaces the default. |
LinkComponent | ElementType | - | Wrapper used to link each default card/highlight to its post.href. Defaults to a plain <a>; the next package passes a next/link for client navigation + prefetch. The wrapper carries display: contents so it doesn't disturb the grid/flex layout. Islands are NOT wrapped — they own their own linking. |
disableAnimation | boolean | - | Disable per-card scroll-reveal animation. By default each card/highlight animates individually as it enters the viewport; the wrapping Section is expected to NOT animate (so the whole stack doesn't fade in together). |
dividers | boolean | false | Draw a 1px --color-separator-line hairline between adjacent rows in the row (and split lead row) containers — the Figma archive list shape. Grids are unaffected (a row hairline doesn't apply across columns). Defaults to false (today's plain flex-gap container). |
labels | { loadMore?, loading?, previous?, next?, readMore?, pagination?, empty? } | - | Labels for the interactive controls (override for i18n). |
Two props are not obvious from their types:
LinkComponentwraps each default card and the highlight in a link topost.href, withdisplay: contentsso the link does not take part in the grid. It defaults to<a>in React and tonext/linkin Next.js. Islands are never wrapped.labelstranslates the built-in text:loadMore,loading,previous,next,readMore,pagination(the pagination landmark's name) andempty(default "No posts found.").
RevealLink to this section
Each card reveals on its own as it scrolls in. The list root and the divider container are <Stack allowChildAnimation>, because a plain Stack cancels the reveals inside it. In a grid row the cards cascade left to right: the posts CSS layer gives each column one delay step of var(--tw-stagger-delay, 120ms), only at the breakpoints where that column count applies. One-column grids and the row view are not staggered. Set --tw-stagger-delay to retune the step, or pass disableAnimation to turn the reveals off. See Motion.
HTML and CSSLink to this section
The layout comes from the posts CSS layer (blocks.posts, on by default), not from Tailwind grid utilities, so it renders in any project whatever Tailwind scans. The layer styles .posts-list*, .post-card*, .post-highlight*, .post-meta*, .archive-filter* and .post-share*. Turn it off with blocks: { posts: false } only when the project does not use posts. See Component CSS blocks.
<div class="stack flex flex-col gap-md aos-allow-children posts-list posts-list-grid3">
<div class="posts-list-grid posts-list-grid-3">
<a class="post-card-link contents" href="/journal/designing-with-tokens">
<article class="post-card post-card-grid3 flex flex-col gap-4">
<div class="post-card-media media-wrapper aspect-[4/3]">
<img src="/media/mountains.webp" alt="" loading="lazy" />
</div>
<div class="post-card-body flex flex-col gap-4">
<span class="post-card-label text-label">Design</span>
<h3 class="post-card-title color-heading text-h4">Designing with tokens from day one</h3>
<div class="post-meta post-card-meta text-small flex flex-wrap items-center gap-x-2">
<span class="post-meta-author">Mira Kovač</span>
<span class="post-meta-separator" aria-hidden="true">•</span>
<time class="post-meta-date" datetime="2026-09-14">September 14, 2026</time>
</div>
<p class="post-card-excerpt color-body text-body line-clamp-3">How a small team…</p>
</div>
</article>
</a>
</div>
</div>The list is at most --container-width wide and centered, so it does not bleed edge to edge outside a Section. The card root sets its color from --color-typography-body, so the title, excerpt and byline follow the active color system on a themed band.
The per-column stagger lives in this layer and out-declares the reveal animation's own animation shorthand. To override the delay from your own CSS, use the app layer (or !important), not a more specific selector. See Cascade layers.
| Class | Styles |
|---|---|
posts-list | width: 100%;max-width: var(--container-width);margin-inline: auto; |
posts-list-grid | & { display: grid; grid-template-columns: minmax(0, 1fr); column-gap: var(--gap-sm, 1.25rem); row-gap: var(--gap-md, 2.5rem); }& > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); }& > * > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); } |
posts-list-row | display: flex;flex-direction: column;row-gap: var(--gap-md, 2.5rem); |
posts-list-grid-2 | @media (width >= 640px) { & { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (width >= 640px) { & > :nth-child(2n - 1) { --post-card-stagger-delay: 0ms; } }@media (width >= 640px) { & > :nth-child(2n) { --post-card-stagger-delay: calc(var(--tw-stagger-delay, 120ms) * 1); } } |
posts-list-grid-3 | @media (width >= 640px) { & { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (width >= 1192px) { & { grid-template-columns: repeat(3, minmax(0, 1fr)); } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n - 1) { --post-card-stagger-delay: 0ms; } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n) { --post-card-stagger-delay: calc(var(--tw-stagger-delay, 120ms) * 1); } }+ 3 more rules |
posts-list-grid-4 | @media (width >= 640px) { & { grid-template-columns: repeat(2, minmax(0, 1fr)); } }@media (width >= 1192px) { & { grid-template-columns: repeat(4, minmax(0, 1fr)); } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n - 1) { --post-card-stagger-delay: 0ms; } }@media (width >= 640px) and (width < 1192px) { & > :nth-child(2n) { --post-card-stagger-delay: calc(var(--tw-stagger-delay, 120ms) * 1); } }+ 4 more rules |
posts-list-interspace | width: 100%; |
posts-list-empty | color: color-mix(in srgb, currentColor 60%, transparent);text-align: center;padding-block: var(--gap-md, 2.5rem); |
posts-list-actions | display: flex;align-items: center;justify-content: center;gap: var(--gap-sm, 1.25rem); |
posts-list-load-more | &:disabled, .posts-list-load-more[disabled] { opacity: 0.4; cursor: not-allowed; } |
posts-list-pagination | & { display: flex; justify-content: center; align-items: center; gap: var(--gallery-navigation-gap-x, 10px); }+ 13 more rules |
post-card | & { color: var(--color-typography-body); }.posts-list-grid > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); }.posts-list-grid > * > .post-card { animation-delay: var(--post-card-stagger-delay, 0ms); } |
post-card-label | & { color: color-mix(in srgb, currentColor 60%, transparent); }&.text-label { color: color-mix(in srgb, currentColor 60%, transparent); } |
post-highlight-label | & { color: color-mix(in srgb, currentColor 60%, transparent); }&.text-label { color: color-mix(in srgb, currentColor 60%, transparent); } |
post-type | color: color-mix(in srgb, currentColor 60%, transparent); |
text-label | & { font-family: var(--font-label-font-family); font-size: var(--typography-label-font-size); font-weight: var(--font-label-font-weight); letter-spacing: var(--typography-label-letter-spacing); line-height: var(--typography-label-line-height); text-transform: uppercase; text-decoration: none; font-style: var(--font-label-font-style); }.post-card-label.text-label { color: color-mix(in srgb, currentColor 60%, transparent); }+ 1 more rule |
post-card-title | text-wrap: balance; |
post-card-read-more | color: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline; |
post-highlight-read-more | color: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline; |
post-card-media-placeholder | width: 100%;height: 100%;background-color: color-mix(in srgb, currentColor 6%, transparent); |
post-card-row | @media (width >= 640px) { & .post-card-body { align-self: start; justify-content: flex-start; } } |
post-card-body | @media (width >= 640px) { .post-card-row .post-card-body { align-self: start; justify-content: flex-start; } } |
post-highlight | position: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px); |
highlight-card | position: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px); |
post-highlight-scrim | position: absolute;inset: 0;background: linear-gradient(to top, rgba(0,0,0,0.75) 0%, rgba(0,0,0,0.35) 35%, rgba(0,0,0,0) 70%);pointer-events: none; |
post-highlight-overlay | position: relative;z-index: 1; |
post-highlight-excerpt | color: color-mix(in srgb, #ffffff 85%, transparent); |
post-meta | display: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent); |
post-byline-meta | display: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent); |
post-meta-avatar | width: var(--post-byline-avatar-size, 1.75em);height: var(--post-byline-avatar-size, 1.75em);border-radius: 9999px;object-fit: cover;flex-shrink: 0; |
post-meta-separator | opacity: 0.5; |
post-meta-author-role | & { opacity: 0.7; }&::before { content: ", "; } |
archive-filter | display: flex;flex-direction: column;gap: var(--gap-sm, 1.25rem); |
archive-filter-bar | display: flex;flex-wrap: wrap;align-items: center;gap: var(--gap-sm, 1.25rem); |
archive-filter-label | flex: 0 0 auto;color: color-mix(in srgb, currentColor 60%, transparent); |
archive-filter-search | flex: 1 1 16rem;min-width: 0;margin: 0; |
archive-filter-select | flex: 1 1 16rem;min-width: 0;margin: 0; |
archive-filter-chips | display: flex;flex-wrap: wrap;align-items: center;gap: var(--chip-gap, 0.5rem);flex: 1 1 16rem;min-width: 0; |
archive-filter-chip-toggle | cursor: pointer;appearance: none; |
archive-filter-chip-toggle-active | background-color: var(--color-chip-hover-background);border-color: var(--color-chip-hover-border);color: var(--color-chip-hover-text); |
archive-filter-active | display: flex;flex-wrap: wrap;align-items: center;gap: var(--chip-gap, 0.5rem); |
archive-filter-chip | & { appearance: none; cursor: pointer; display: inline-flex; align-items: center; gap: var(--chip-gap); font-size: var(--chip-font-size); line-height: var(--chip-line-height); letter-spacing: calc(var(--chip-letter-spacing, 0) * 1px); padding-inline: var(--chip-padding-x); padding-block: var(--chip-padding-y); border-radius: var(--chip-border-radius); border-style: solid; border-width: var(--chip-border-width); background-color: var(--color-chip-normal-background); border-co…+ 1 more rule |
archive-filter-chip-remove | font-size: 1.2em;line-height: 1;opacity: 0.7; |
post-related-title | margin-bottom: var(--gap-sm, 1.25rem); |
post-single | & .article > :not(section, .figure-w-full, .figure-w-screen, .figure-w-container, a:not([class*="card"]), div:not(.columns, .stack, [class*="card"], [class*="accordion"])) { max-width: var(--post-reading-width, calc(var(--container-width) - (var(--article-padding-x) * 2))); } |
post-share-button-label | font-size: var(--text-small-font-size, 0.875rem);line-height: 1; |
post-share-icon-label | & .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }& .post-share-list > button { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; } |
post-share-list | .post-share-icon-label .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }.post-share-icon-label .post-share-list > button { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }.post-share-pills .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }+ 3 more rules |
post-share-pills | & .post-share-list > a { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }& .post-share-list > button { display: inline-flex; align-items: center; gap: var(--gap-xs, 0.5rem); width: auto; }& .post-share-list > a { padding: var(--gap-xs, 0.5rem) var(--gap-sm, 1.25rem); border-radius: 9999px; border: 1px solid var(--color-separator-line, currentColor); }+ 1 more rule |
post-share-sticky-bottom | position: fixed;left: 0;right: 0;bottom: 0;z-index: 40;justify-content: center;background-color: var(--color-foundations-surface-bg);border-top: 1px solid var(--color-separator-line, currentColor);padding-block: var(--gap-sm, 1.25rem); |
post-share-floating-sidebar | @media (width >= 1192px) { & { position: fixed; left: var(--gap-lg, 1.5rem); top: 50%; transform: translateY(-50%); flex-direction: column; align-items: center; z-index: 40; } } |
Next.jsLink to this section
The @systhemaui/next PostsList has the same props as the React one and fills in two defaults:
- Cards render media with
next/image(fill,objectFit="cover") and asizeshint fromsizesForLayout(view). - The highlight card renders
fill,sizes="100vw"andpriority, so it loads eagerly as the likely LCP image. - Each card links through
next/link.
A renderImage, renderHighlightImage or LinkComponent you pass wins over these defaults. The component is a client component; use it from server components with serializable props, and from a client module when you pass fetchMore, injectItems or islands.
In a Payload project, @systhemaui/payload/next also exports the archive pipeline (resolveArchiveData, PostsListClient, ArchiveFilterProvider, useArchiveFilters, ArchiveFilterControl) for building a custom archive with search, load more and ads. See Archive pages.
AccessibilityLink to this section
- The list sets
aria-busywhile a page loads. - In
loadMoreandinfinitemodes a visually hiddenrole="status"region announces the loading text, then theshown / totalcount after each page. - The empty state ("No posts found.") is an
aria-live="polite"region. - Pagination is a
<nav>named bylabels.pagination; its buttons have visually hidden "Previous" and "Next" text, and changing page scrolls the top of the listing into view. - The whole card is one link, so its text (label, title, byline and excerpt) is the link's name. Keep titles short and descriptive.
- Card titles are
<h3>and the highlight title is<h2>. When the page outline needs other levels, render the standalone cards withheadingLevel, or use an island.