Designing with tokens from day one
How a small team keeps a large site consistent without slowing its editors down.
Listing cards and highlight cards for the Posts module.
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.
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>
)
}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 sectionA 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).
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.
| Layout | Media aspect ratio | Excerpt | Read more |
|---|---|---|---|
grid2 | 4:3, 16:9 from md | on | off |
grid3 or none | 4:3 | on | off |
grid4 | 4:3 | off | off |
row | 4:3, 3:2 from md, beside the text | off | on |
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>
)
}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.
| Card | show parts | classNames parts |
|---|---|---|
PostCard | media, label, title, author, date, excerpt, readMore | the same, plus root |
HighlightCard | media, overlay, label, title, author, date, excerpt, readMore | the 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).
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 sectionA 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>
)
}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.
PostCard propsLink to this section| Prop | Type | Default | Description |
|---|---|---|---|
post (required) | PostView | - | The resolved, serializable view-model for this post. |
config | ResolvedCardConfig | - | 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. |
className | string | - | |
theme | ColorSystem (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. |
headingLevel | 2 | 3 | 4 | 3 | Heading level for the card title (default 3). |
readMoreLabel | string | 'Read more' | Label for the optional "read more" affordance (default "Read more"). |
disableAnimation | boolean | false | 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). |
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| Prop | Type | Default | Description |
|---|---|---|---|
post (required) | PostView | - | The resolved, serializable view-model for the highlighted post. |
config | ResolvedHighlightCardConfig | - | The resolved show/classNames for the highlight card. Defaults to the Figma defaults when omitted (all parts ON). Pulled from listing.highlightCard. |
className | string | - | |
theme | ColorSystem (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. |
headingLevel | 1 | 2 | 3 | 2 | Heading level for the title (default 2 — it's the listing's lead card). |
readMoreLabel | string | 'Read more' | Label for the optional "read more" affordance (default "Read more"). |
disableAnimation | boolean | false | Disable this card's scroll-reveal animation (default reveals on scroll). |
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.
| 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; } } |
PostCard in Next.jsLink to this sectionWhen 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} />| Prop | Type | Default | Description |
|---|---|---|---|
linked | boolean | true | Wrap 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. |
className | string | - | |
disableAnimation | boolean | - | 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). |
theme | ColorSystem (dark, default) | - | |
post (required) | PostView | - | The resolved, serializable view-model for this post. |
config | ResolvedCardConfig | - | 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. |
headingLevel | 2 | 3 | 4 | - | Heading level for the card title (default 3). |
readMoreLabel | string | - | Label for the optional "read more" affordance (default "Read more"). |
HighlightCard in Next.jsLink to this sectionThe 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.
| Prop | Type | Default | Description |
|---|---|---|---|
linked | boolean | true | Wrap 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. |
className | string | - | |
disableAnimation | boolean | - | Disable this card's scroll-reveal animation (default reveals on scroll). |
theme | ColorSystem (dark, default) | - | |
post (required) | PostView | - | The resolved, serializable view-model for the highlighted post. |
config | ResolvedHighlightCardConfig | - | The resolved show/classNames for the highlight card. Defaults to the Figma defaults when omitted (all parts ON). Pulled from listing.highlightCard. |
headingLevel | 1 | 2 | 3 | - | Heading level for the title (default 2 — it's the listing's lead card). |
readMoreLabel | string | - | Label for the optional "read more" affordance (default "Read more"). |
<article> with a real heading: <h3> for PostCard (headingLevel 2 to 4) and <h2> for HighlightCard (headingLevel 1 to 3).<time datetime> with the ISO date.aria-hidden, because the whole card is the link; it does not repeat in the link's name.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.