Skip to content
ilinxa/pro-ui

News Card

alphav0.4.0

Magazine-style news card in five sizes — role-aware editor and viewer modes, permissions matrix, badges, paywall and sensitivity gates.

Category: Data DisplayUpdated: 2026-08-11Created: 2026-05-01Author: ilinxa

Context

First component in the news-domain family (siblings: page-hero, magazine-layout, related-articles-ribbon-01 queued). v0.3.0 mirrors the post-card v0.3.2 trait set translated into editorial vocabulary (`editor` instead of `owner`; `publish/unpublish/feature` instead of `pin/markSensitive`; `paywall` distinct from `sensitive`). Strictly additive on v0.2: every v0.2.x consumer keeps working unchanged (all new fields/props/labels optional; existing `author` string + `date` field preserved alongside new structured `authorEntity` + `publishedAt`). Engagement-bar-01 is NOT a peer dep — consumers compose it via the `renderEngagementCounts` slot on news article detail pages per the documented integration pattern. F-S1 lock applied for cross-procomp imports (RELATIVE paths to specific files; `CommentMenuItem` reused from comment-thread). F-cross-13 defensive DropdownMenu pattern (direct trigger, no asChild). Migration origin: kasder kas-social-front-v0 NewsCard.tsx (v0.1 base; v0.3 expansion is greenfield).

Installation

Initialize shadcn (once per project)Seeds lib/utils.ts and components.json. Skip if you've already used any shadcn component.
pnpm dlx shadcn@latest init
Register the @ilinxa namespace (once per project)Add to your components.json. Merge with existing config.
"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}
Install the component
pnpm dlx shadcn@latest add @ilinxa/news-card

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/news-card-fixtures

Preview

Demo source

demo.tsxtsx

Usage

When to use

Reach for NewsCard when you need a magazine-style article preview that flexes across different layout densities — a featured hero, a 2-col horizontal article, a vertical grid cell, a sidebar thumb, or a list row — all driven by a single variant prop. Built for news, blog, editorial, or documentation feeds.

Minimal example

import { NewsCard } from "@/components/news-card";

<NewsCard
  item={{
    id: "1",
    title: "Headline",
    image: "/cover.jpg",
    excerpt: "Lead paragraph…",
    category: "Sustainability",
    author: "A. Yilmaz",
    date: "2026-05-01",
    readTime: 8,
  }}
  variant="medium"
  href="/news/1"
  categoryStyles={{
    Sustainability: "bg-emerald-500/10 text-emerald-600",
  }}
/>;

Five variants

  • featured — full-bleed hero card with image overlay, serif title, full meta row, and a Read-More CTA. The badge wears a bg-black/40 backdrop-blur-sm wrapper for legibility.
  • large — 2-column horizontal card. Image left, serif title + excerpt + meta right. Good as the lead article in a main column under a featured hero.
  • medium — vertical card with image-on-top, optional view-chip overlay, and a kicker footer (separator + author/date). The default workhorse for grid cells.
  • small — compact horizontal thumb tile. Sidebar density; no excerpt, no actions slot.
  • list — full-width row with badge + title + truncated excerpt + chevron. The chevron is replaced by the actions slot when provided.

Polymorphic root

The card renders <a>by default. Pass your framework's link via linkComponent for SPA navigation:

import NextLink from "next/link";

<NewsCard
  item={item}
  href={`/news/${item.id}`}
  linkComponent={NextLink}
/>;

Nested interactives — actions slot

The card uses an overlay-link pattern: a real <a> covers the whole card via position: absolute; inset: 0. This keeps the entire surface clickable AND lets you embed independently-clickable buttons via the actions prop. The actions cluster gets position: relative; z-index: 10 so it sits above the overlay.

<NewsCard
  item={item}
  variant="medium"
  href={`/news/${item.id}`}
  actions={
    <div className="flex gap-2">
      <button
        onClick={(e) => {
          e.preventDefault();
          e.stopPropagation();
          bookmark(item.id);
        }}
        aria-label="Bookmark"
      >
        <Bookmark />
      </button>
      <button onClick={(e) => { e.preventDefault(); e.stopPropagation(); share(item); }} aria-label="Share">
        <Share2 />
      </button>
    </div>
  }
/>;

Inside the action button's onClick, call e.preventDefault() + e.stopPropagation()so the click doesn't bubble to the link overlay's default navigation. The Demo's "Actions slot" tab includes a working example.

Localization — labels + formatters

<NewsCard
  item={item}
  variant="featured"
  labels={{ readMore: "Devamını Oku", minutesRead: "dk okuma" }}
  formatRelativeTime={(d) => myI18n.formatRelative(d)}
  formatDate={(d) => d.toLocaleDateString("tr-TR", {
    day: "numeric", month: "long", year: "numeric",
  })}
/>;

Soft-fail on missing fields

Only id, title, and image are required. Missing excerpt, category, author, date, readTime, or views are gracefully omitted — no empty placeholders, no layout glitches.

Customizing the title font

The title uses Tailwind's font-serif utility which maps to the pro-ui-wide --font-serif CSS variable (default: Playfair Display). Override at any DOM scope:

/* App-wide override */
:root { --font-serif: "Lora", Georgia, serif; }

/* Section-scoped override */
<section style={{ "--font-serif": '"Cormorant Garamond"' } as any}>
  <NewsCard ... />
</section>

/* Per-card override */
<NewsCard
  item={item}
  titleClassName="font-sans tracking-tight"
/>;

Accessibility

  • The link overlay's accessible name is the heading text via aria-labelledby; override with ariaLabel.
  • Focus-visible ring covers the whole card surface (uses :has(a:focus-visible)), not just the invisible link rectangle.
  • Decorative icons (Calendar, Clock, User, Eye, ArrowRight) are aria-hidden.
  • The view-chip on mediumannounces "N views" via aria-label.
  • All transitions wrapped in motion-safe: — reduced-motion users see static cards.

Features

  • 5 visual variants — featured / large / medium / small / list — dispatched via single `variant` prop
  • Overlay-link pattern — whole card clickable; optional `actions` slot for nested interactives
  • Polymorphic root — `linkComponent` slot accepts NextLink / RemixLink / plain <a>
  • Soft-fail item shape — only id/title/image required; all other fields optional
  • Editorial typography via pro-ui-wide --font-serif CSS variable (Playfair Display default)
  • Localizable — `formatRelativeTime` + `formatDate` callbacks + `labels` object
  • Theming via `categoryStyles` map + `titleClassName` / `imageClassName` / `className` slots
  • v0.3.0 — `viewerMode: 'editor' | 'viewer'` opt-in two-mode toggle (no auto-derivation from identity)
  • v0.3.0 — 19-capability `NewsCardPermissions` matrix + `canPerformAction(action, item)` universal predicate
  • v0.3.0 — Moderator section in kebab — orthogonal `canModerate` + `moderatorActions(item)` slot (divider-separated)
  • v0.3.0 — 16 mutation handlers (12 editor-side + 4 reader-side) separate from engagement
  • v0.3.0 — Kebab dropdown integrated in 4 variants (featured/large/medium/list — small skips per density)
  • v0.3.0 — Dual-mode `defaultNewsCardKebabActions` helper: legacy minimal kebab when no role-aware args; role-aware items when any set
  • v0.3.0 — `kebabActions(item)` full-takeover slot
  • v0.3.0 — 31 new optional `NewsCardItem` fields: slug / authorEntity / publisher / publishedAt / updatedAt / scheduledFor / status / visibility / topics / tags / language / availableTranslations / isPinned / isFeatured / isBreaking / isLive / isExclusive / isSponsored / sponsorLabel / liveUpdateCount / lastLiveUpdateAt / sensitivity / paywall / commentsEnabled / commentCount / likeCount / isLiked / bookmarkCount / isBookmarked / shareCount / quotedArticle
  • v0.3.0 — `ContentStatus` closed enum (draft/scheduled/published/archived); `NewsVisibility` extensible string union (public/members/subscribers/staff/unlisted + branded)
  • v0.3.0 — Editorial badge stack with frozen priority order: Breaking → Live → Exclusive → Featured → Pinned → Sponsored → status (editor mode). Uniform shape across all badges (h-5 + px-1.5 + text-[10px] + uppercase + rounded + shrink-0), hierarchy via saturation: vivid solid (Breaking/Live red) → accent solid (Exclusive amber / Featured primary) → subtle solid (Pinned card-tone / Sponsored / Status). Drop-shadow on vivid tier for legibility against bright hero images.
  • v0.3.0 — Badge placement split (medium variant): state group (Breaking/Live/Pinned/Sponsored/Status) at top-right overlay; curation group (Exclusive/Featured) as kicker row above title. `NewsBadges` accepts `group: "all" | "state" | "curation"` (default `"all"`).
  • v0.3.0 — Status badge (editor-mode only) for draft/scheduled/archived
  • v0.3.0 — Visibility badge for non-public access tiers
  • v0.3.0 — Sponsor badge with `sponsorLabel` template ("Sponsored by {name}")
  • v0.3.0 — Live-update sub-line — "Updated 3m ago · 14 updates" when isLive + lastLiveUpdateAt set
  • v0.3.0 — Paywall gate over MEDIA only — title + author + footer + engagement counts stay visible. `paywall.preview` substitutes for `item.excerpt` in the body (variants compute `displayExcerpt = paywall.preview ?? item.excerpt`). Distinct from sensitive (monetization vs content-warning).
  • v0.3.0 — Sensitive content gate over media only — `contentWarnings[]` listing + keyboard-operable reveal + motion-reduce snap. `compact` prop drops heading + warnings list and shrinks to ~60px content for the small variant's 96×96 thumb.
  • v0.3.0 — Quoted article mini-card (medium + list variants) — renders inner `<NewsCard variant="small">` with all gates suppressed (paywall/sensitive/badges/engagement) for a clean citation preview. Recursion-strip helper prevents infinite nesting.
  • v0.3.0 — Light engagement counts row: like/comment/bookmark/share chips with handler-driven interactivity + bistate fill
  • v0.3.0 — `renderEngagementCounts` slot lets consumers compose `<EngagementBar>` on detail pages (engagement-bar NOT a peer dep)
  • v0.3.0 — Structured `NewsAuthorByline` with avatar + role + verified tick, soft-compat fallback to string `author`
  • v0.3.0 — `NewsPublisherRow` standalone publisher chip with logo + name + click handler
  • v0.3.0 — 9 render slots: renderBadges / renderAuthor / renderExcerpt / renderPaywallGate / renderSensitiveGate / renderQuoted / renderEngagementCounts / kebabActions / moderatorActions
  • v0.3.0 — 7 opt-outs: disableBadgesRender / disableAuthorRender / disableExcerptRender / disablePaywallGate / disableSensitiveGate / disableQuotedRender / disableEngagementCounts
  • v0.3.0 — 11 sub-exports: NewsBadges / StatusBadge / VisibilityBadge / SponsorBadge / LiveUpdateLine / NewsAuthorByline / NewsPublisherRow / NewsPaywallGate / ContentSensitiveGate / QuotedArticleCard / NewsEngagementCounts + NewsKebab
  • v0.3.0 — 10 per-entity click handlers: onAuthorClick / onPublisherClick / onCategoryClick / onTopicClick / onTagClick / onQuotedClick / onCommentCountClick / onTranslate / onRevealPaywall / onRevealSensitive
  • v0.3.0 — 11-method imperative handle: openKebab / triggerEdit / triggerDelete / triggerPublish / triggerUnpublish / triggerPin / triggerFeature / revealPaywall / revealSensitive / reset(next) / getCurrentItem
  • v0.3.0 — Local-mirror state for `paywallRevealed` + `sensitiveRevealed` flags; cleared on `reset(next)`
  • v0.3.0 — ~50 i18n label keys covering kebab / visibility / status / editorial badges / paywall / sensitive / engagement / live
  • v0.3.0 — Library does NOT ship visibility / category / schedule pickers — single-trigger callbacks let host open its own UI
  • WCAG 2.5.5 — all interactive elements ≥44×44 (kebab triggers, engagement chips, paywall CTA, sensitive reveal)
  • motion-safe: prefix on all transitions; reduced-motion users see static cards + snap-reveal gates
  • Focus-visible ring covers full card via :has(a:focus-visible); kebab + sub-buttons own focus rings
  • F-S1 lock — RELATIVE cross-procomp imports (CommentMenuItem via ../comment-thread/types)
  • F-cross-13 defensive — DropdownMenuTrigger as the trigger button directly (no asChild)
  • RTL aware — chevron + arrow icons flip via rtl:rotate-180
  • React.memo wrapped — stable item refs prevent re-renders in long feeds

Tags

news-carddatacardnewseditorialmagazinemigrationtier-2compositerole-awarepermissionspaywallkebabengagement-countseditor-mode

Dependencies

shadcn primitives: badge, dropdown-menu
npm peer deps: lucide-react@^1.11.0
internal: comment-thread