Docs

This page isn't translated yet

PostCard and HighlightCard

Listing cards and highlight cards for the Posts module.

On this page

PostCard is the default card for one post in a grid or row list, and HighlightCard is the full-bleed card for a lead post. PostsList renders both for you; use them directly when you lay posts out yourself, for example a single featured post in a sidebar.

PostCard
import { PostCard, type PostView } from '@systhemaui/next'

const post: PostView = {
  id: 1,
  title: 'Designing with tokens from day one',
  label: 'Design',
  href: '#',
  excerpt: 'How a small team keeps a large site consistent without slowing its editors down.',
  tags: [],
  postType: 'article',
  author: { name: 'Mira Kovač', avatar: { url: '/demo-assets/avatar-1.webp' } },
  publishedAt: '2026-09-14T09:00:00.000Z',
  media: { url: '/demo-assets/mountains.webp', alt: '', width: 1920, height: 1080 },
  layout: 'grid3',
  doc: null,
}

export default function Demo() {
  return (
    <div className="w-full max-w-sm">
      <PostCard post={post} />
    </div>
  )
}

ImportLink to this section

import { HighlightCard, PostCard } from '@systhemaui/next'

In a React app without Next.js, import them from @systhemaui/react. Both take a PostView as post.

PostCardLink to this section

A vertical stack of media, label, title, byline (author and date) and excerpt, with an optional "read more". The React card is presentational: it renders no link and fetches nothing. The Next.js card links the whole card to post.href (see Next.js).

Default parts per layoutLink to this section

post.layout (grid2, grid3, grid4 or row) decides the media aspect ratio and which parts show by default. PostsList sets it from its view; set it yourself when you render a card on its own.

LayoutMedia aspect ratioExcerptRead more
grid24:3, 16:9 from mdonoff
grid3 or none4:3onoff
grid44:3offoff
row4:3, 3:2 from md, beside the textoffon

Media, label, title, author and date are on in every layout. On small screens every layout is stacked, media first. From md the row card becomes a two-column grid (1fr 2fr) with the text top-aligned.

import { PostCard, type PostView } from '@systhemaui/next'

const post: PostView = {
  id: 1,
  title: 'Shipping a multilingual site in a week',
  label: 'Engineering',
  href: '#',
  tags: [],
  postType: 'article',
  author: { name: 'Jonas Weber' },
  publishedAt: '2026-09-02T09:00:00.000Z',
  media: { url: '/demo-assets/city.webp', alt: '', width: 1920, height: 1080 },
  layout: 'row',
  doc: null,
}

export default function Demo() {
  return (
    <div className="w-full">
      <PostCard post={post} />
    </div>
  )
}

PartsLink to this section

config.show turns parts on or off and config.classNames overrides a part's classes. An override is merged with tailwind-merge, so aspect-square replaces the default aspect-[4/3] while non-conflicting classes are added.

Cardshow partsclassNames parts
PostCardmedia, label, title, author, date, excerpt, readMorethe same, plus root
HighlightCardmedia, overlay, label, title, author, date, excerpt, readMorethe same, plus root

PostCard's config also takes four presentation values, applied as inline styles so any value works without a Tailwind build: aspectRatio (CSS aspect-ratio of the media, such as '1/1'), mediaGap (the gap between media and text, such as 'var(--gap-md)'), and for the row layout from md up mediaWidth (grid-template-columns, default '1fr 2fr') and bodyAlign (align-self of the text).

Square media, no excerpt, a read-more link
import { PostCard, type PostView } from '@systhemaui/next'

const post: PostView = {
  id: 1,
  title: 'A field guide to spacing',
  label: 'Design',
  href: '#',
  excerpt: 'Why the rich-text rhythm beats hand-tuned margins.',
  tags: [],
  postType: 'article',
  author: { name: 'Mira Kovač' },
  publishedAt: '2026-08-28T09:00:00.000Z',
  media: { url: '/demo-assets/coast.webp', alt: '', width: 1800, height: 1200 },
  layout: 'grid3',
  doc: null,
}

export default function Demo() {
  return (
    <div className="w-full max-w-xs">
      <PostCard
        post={post}
        config={{ show: { excerpt: false, readMore: true }, aspectRatio: '1/1' }}
        readMoreLabel="Read the guide"
      />
    </div>
  )
}

A post without media renders an empty placeholder box of the same aspect ratio, so a grid stays aligned.

HighlightCardLink to this section

A full-bleed card: the media fills the card, a dark scrim covers it, and the label, title, byline and excerpt sit bottom left in white, at most half the card's width from md. It is 4:5 on small screens and 16:9 from md. Every part is on by default except readMore; turn the scrim off with config.show.overlay: false.

import { HighlightCard, type PostView } from '@systhemaui/next'

const post: PostView = {
  id: 1,
  title: 'Designing with tokens from day one',
  label: 'Design',
  href: '#',
  excerpt: 'How a small team keeps a large site consistent without slowing its editors down.',
  tags: [],
  postType: 'article',
  author: { name: 'Mira Kovač', avatar: { url: '/demo-assets/avatar-1.webp' } },
  publishedAt: '2026-09-14T09:00:00.000Z',
  media: { url: '/demo-assets/city.webp', alt: '', width: 1920, height: 1080 },
  doc: null,
}

export default function Demo() {
  return (
    <div className="w-full">
      <HighlightCard post={post} />
    </div>
  )
}

ColorLink to this section

PostCard follows the active color system like Heading and Paragraph: the title carries color-heading, the excerpt color-body, and the card root sets its color from --color-typography-body so the label and byline mute against the right base. Put a card on a themed Section and its text follows that theme, or pass theme to set data-theme on the card itself. A text color in classNames.title or classNames.excerpt still wins, because utilities beat the component layer. HighlightCard is always white on its scrim.

PropsLink to this section

PostCard propsLink to this section

PropTypeDefaultDescription
post (required)PostView-The resolved, serializable view-model for this post.
configResolvedCardConfig-The resolved show/classNames for this card layout. Defaults to the Figma defaults when omitted (media/label/title/author/date/excerpt ON, readMore OFF). Pulled from the per-layout listing.cards.<layout> plugin config.
classNamestring-
themeColorSystem (dark, default)-
renderImage((media: { url: string; alt?: string | undefined; width?: number | undefined; height?: number | undefined; srcset?: string | undefined; isPlaceholder?: boolean | undefined; }) => ReactNode)-Render-prop for the media region. The next package passes an optimized next/image here; react renders a plain <img> by default.
headingLevel2 | 3 | 43Heading level for the card title (default 3).
readMoreLabelstring'Read more'Label for the optional "read more" affordance (default "Read more").
disableAnimationbooleanfalseDisable this card's scroll-reveal animation. By default each card carries the config animationClasses so posts reveal individually as they enter the viewport (rather than the whole listing/Section fading in at once).

renderImage(media) replaces the default <img> (the Next.js card passes next/image). disableAnimation turns off the card's scroll reveal; by default it carries packages.react.animationClasses from your config.

HighlightCard propsLink to this section

PropTypeDefaultDescription
post (required)PostView-The resolved, serializable view-model for the highlighted post.
configResolvedHighlightCardConfig-The resolved show/classNames for the highlight card. Defaults to the Figma defaults when omitted (all parts ON). Pulled from listing.highlightCard.
classNamestring-
themeColorSystem (dark, default)-
renderImage((media: { url: string; alt?: string | undefined; width?: number | undefined; height?: number | undefined; srcset?: string | undefined; isPlaceholder?: boolean | undefined; }) => ReactNode)-Render-prop for the full-bleed background media. The next package passes an optimized next/image (fill); react renders a plain <img> by default.
headingLevel1 | 2 | 32Heading level for the title (default 2 — it's the listing's lead card).
readMoreLabelstring'Read more'Label for the optional "read more" affordance (default "Read more").
disableAnimationbooleanfalseDisable this card's scroll-reveal animation (default reveals on scroll).

HTML and CSSLink to this section

The .post-card* and .post-highlight* hooks come from the posts CSS layer. A grid card's markup is on PostsList; a highlight card looks like this:

<article class="post-highlight relative aspect-[4/5] overflow-hidden rounded-xl md:aspect-video">
  <div class="post-highlight-media absolute inset-0">
    <img src="/media/mountains.webp" alt="" class="size-full object-cover" />
  </div>
  <div class="post-highlight-scrim absolute inset-0" aria-hidden="true"></div>
  <div
    class="post-highlight-overlay absolute inset-0 flex flex-col justify-end gap-4 p-7 text-white md:max-w-[50%]"
  >
    <span class="post-highlight-label text-label">Design</span>
    <h2 class="post-highlight-title text-h4">Designing with tokens from day one</h2>
    <div class="post-meta post-highlight-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-highlight-excerpt text-body">How a small team…</p>
  </div>
</article>

The scrim gradient is .post-highlight-scrim in the posts layer.

ClassStyles
posts-listwidth: 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-rowdisplay: 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-interspacewidth: 100%;
posts-list-emptycolor: color-mix(in srgb, currentColor 60%, transparent);text-align: center;padding-block: var(--gap-md, 2.5rem);
posts-list-actionsdisplay: 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-typecolor: 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-titletext-wrap: balance;
post-card-read-morecolor: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline;
post-highlight-read-morecolor: var(--color-link-text-default, currentColor);font-weight: 600;text-decoration-line: underline;
post-card-media-placeholderwidth: 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-highlightposition: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px);
highlight-cardposition: relative;overflow: hidden;border-radius: var(--card-default-border-radius, 12px);
post-highlight-scrimposition: 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-overlayposition: relative;z-index: 1;
post-highlight-excerptcolor: color-mix(in srgb, #ffffff 85%, transparent);
post-metadisplay: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent);
post-byline-metadisplay: flex;align-items: center;flex-wrap: wrap;gap: 0.5em;color: color-mix(in srgb, currentColor 60%, transparent);
post-meta-avatarwidth: 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-separatoropacity: 0.5;
post-meta-author-role& { opacity: 0.7; }&::before { content: ", "; }
archive-filterdisplay: flex;flex-direction: column;gap: var(--gap-sm, 1.25rem);
archive-filter-bardisplay: flex;flex-wrap: wrap;align-items: center;gap: var(--gap-sm, 1.25rem);
archive-filter-labelflex: 0 0 auto;color: color-mix(in srgb, currentColor 60%, transparent);
archive-filter-searchflex: 1 1 16rem;min-width: 0;margin: 0;
archive-filter-selectflex: 1 1 16rem;min-width: 0;margin: 0;
archive-filter-chipsdisplay: flex;flex-wrap: wrap;align-items: center;gap: var(--chip-gap, 0.5rem);flex: 1 1 16rem;min-width: 0;
archive-filter-chip-togglecursor: pointer;appearance: none;
archive-filter-chip-toggle-activebackground-color: var(--color-chip-hover-background);border-color: var(--color-chip-hover-border);color: var(--color-chip-hover-text);
archive-filter-activedisplay: 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-removefont-size: 1.2em;line-height: 1;opacity: 0.7;
post-related-titlemargin-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-labelfont-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-bottomposition: 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

PostCard in Next.jsLink to this section

When linked (default true) is on and the post has an href, the whole card is wrapped in next/link, and the media renders through next/image (fill, objectFit="cover") with a sizes hint from sizesForLayout(post.layout). With no href, or linked={false}, it renders the React card. It takes the React props except renderImage, plus linked.

import { PostCard } from '@systhemaui/next'

;<PostCard post={postView} linked={false} />
PropTypeDefaultDescription
linkedbooleantrueWrap the whole card in a next/link to the post's href (prefetching + client navigation). When false (or the post has no href), renders the bare presentational card.
classNamestring-
disableAnimationboolean-Disable this card's scroll-reveal animation. By default each card carries the config animationClasses so posts reveal individually as they enter the viewport (rather than the whole listing/Section fading in at once).
themeColorSystem (dark, default)-
post (required)PostView-The resolved, serializable view-model for this post.
configResolvedCardConfig-The resolved show/classNames for this card layout. Defaults to the Figma defaults when omitted (media/label/title/author/date/excerpt ON, readMore OFF). Pulled from the per-layout listing.cards.<layout> plugin config.
headingLevel2 | 3 | 4-Heading level for the card title (default 3).
readMoreLabelstring-Label for the optional "read more" affordance (default "Read more").

HighlightCard in Next.jsLink to this section

The media renders through next/image with fill, sizes="100vw" and priority, so it loads eagerly as the likely LCP image, and the card links through next/link. Use one highlight card per page, above the fold. It takes the React props except renderImage, plus linked.

PropTypeDefaultDescription
linkedbooleantrueWrap the whole card in a next/link to the post's href. When false (or the post has no href), renders the bare presentational highlight card.
classNamestring-
disableAnimationboolean-Disable this card's scroll-reveal animation (default reveals on scroll).
themeColorSystem (dark, default)-
post (required)PostView-The resolved, serializable view-model for the highlighted post.
configResolvedHighlightCardConfig-The resolved show/classNames for the highlight card. Defaults to the Figma defaults when omitted (all parts ON). Pulled from listing.highlightCard.
headingLevel1 | 2 | 3-Heading level for the title (default 2 — it's the listing's lead card).
readMoreLabelstring-Label for the optional "read more" affordance (default "Read more").

AccessibilityLink to this section

  • The card is an <article> with a real heading: <h3> for PostCard (headingLevel 2 to 4) and <h2> for HighlightCard (headingLevel 1 to 3).
  • The date is a <time datetime> with the ISO date.
  • The "read more" text is aria-hidden, because the whole card is the link; it does not repeat in the link's name.
  • The scrim is aria-hidden. Featured media is usually decorative next to the title, so an empty alt is fine; give media.alt a value when the image carries information the title does not.