Skip to content
ilinxa/pro-ui

News Card

alphav0.4.2

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

CLI can't resolve @ilinxa? The namespace is listed in the official shadcn registry directory, so current CLIs need no configuration. If yours can't resolve it (older or pinned versions, self-hosted mirrors), register it manually in components.json:

"registries": {
  "@ilinxa": "https://ui.ilinxa.com/r/{name}.json"
}

Preview

Demo source

demo.tsxtsx
"use client"; import { useState } from "react";import { Bookmark, BookmarkCheck, Share2 } from "lucide-react";import { Button } from "@/components/ui/button";import { Tabs, TabsContent, TabsTrigger } from "@/components/ui/tabs";import { SwipeTabsList } from "@/components/site/swipe-tabs-list";import { NewsCard } from "./news-card";import {  breakingNewsItem,  draftItem,  dummyCategoryStyles,  dummyNewsCardItems,  editorsPickItem,  paywalledItem,  quotingItem,  sensitiveItem,  sponsoredItem,} from "./dummy-data"; const items = dummyNewsCardItems; function CardActions({  itemId,  bookmarked,  onToggle,}: {  itemId: string;  bookmarked: boolean;  onToggle: (id: string) => void;}) {  return (    <div className="flex gap-2">      <Button        variant="secondary"        size="sm"        onClick={(event) => {          event.preventDefault();          event.stopPropagation();          onToggle(itemId);        }}        aria-label={bookmarked ? "Remove bookmark" : "Bookmark article"}      >        {bookmarked ? (          <BookmarkCheck aria-hidden="true" className="size-4" />        ) : (          <Bookmark aria-hidden="true" className="size-4" />        )}      </Button>      <Button        variant="secondary"        size="sm"        onClick={(event) => {          event.preventDefault();          event.stopPropagation();        }}        aria-label="Share article"      >        <Share2 aria-hidden="true" className="size-4" />      </Button>    </div>  );} export default function NewsCardDemo() {  const [bookmarks, setBookmarks] = useState<Set<string>>(() => new Set());   const toggleBookmark = (id: string) => {    setBookmarks((prev) => {      const next = new Set(prev);      if (next.has(id)) next.delete(id);      else next.add(id);      return next;    });  };   const featured = items[0];  const large = items[1];  const mediums = items.slice(2, 6);  const smalls = items.slice(2, 6);  const lists = items.slice(0, 5);   const log = (label: string) => () =>    console.log(`[news-card demo] ${label}`);   return (    <Tabs defaultValue="featured" className="w-full">      <SwipeTabsList>        <TabsTrigger value="featured">Featured</TabsTrigger>        <TabsTrigger value="large">Large</TabsTrigger>        <TabsTrigger value="medium">Medium</TabsTrigger>        <TabsTrigger value="small">Small</TabsTrigger>        <TabsTrigger value="list">List</TabsTrigger>        <TabsTrigger value="composed">Composed</TabsTrigger>        <TabsTrigger value="actions">Actions slot</TabsTrigger>        <TabsTrigger value="editor">Editor mode</TabsTrigger>        <TabsTrigger value="paywall">Paywall</TabsTrigger>        <TabsTrigger value="sensitive">Sensitive</TabsTrigger>        <TabsTrigger value="quoted">Quoted article</TabsTrigger>        <TabsTrigger value="engagement">Engagement</TabsTrigger>      </SwipeTabsList>       <TabsContent value="featured" className="mt-6">        <NewsCard          item={featured}          variant="featured"          href={`/news/${featured.id}`}          categoryStyles={dummyCategoryStyles}        />      </TabsContent>       <TabsContent value="large" className="mt-6">        <NewsCard          item={large}          variant="large"          href={`/news/${large.id}`}          categoryStyles={dummyCategoryStyles}        />      </TabsContent>       <TabsContent value="medium" className="mt-6">        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">          {mediums.map((item) => (            <NewsCard              key={item.id}              item={item}              variant="medium"              href={`/news/${item.id}`}              categoryStyles={dummyCategoryStyles}            />          ))}        </div>      </TabsContent>       <TabsContent value="small" className="mt-6">        <div className="grid gap-4 md:grid-cols-2">          {smalls.map((item) => (            <NewsCard              key={item.id}              item={item}              variant="small"              href={`/news/${item.id}`}              categoryStyles={dummyCategoryStyles}            />          ))}        </div>      </TabsContent>       <TabsContent value="list" className="mt-6">        <div className="rounded-2xl border border-border/50 bg-card p-4">          {lists.map((item) => (            <NewsCard              key={item.id}              item={item}              variant="list"              href={`/news/${item.id}`}              categoryStyles={dummyCategoryStyles}            />          ))}        </div>      </TabsContent>       <TabsContent value="composed" className="mt-6">        <div className="space-y-8">          <NewsCard            item={featured}            variant="featured"            href={`/news/${featured.id}`}            categoryStyles={dummyCategoryStyles}          />          <div className="grid gap-6 lg:grid-cols-12">            <div className="space-y-6 lg:col-span-8">              <NewsCard                item={large}                variant="large"                href={`/news/${large.id}`}                categoryStyles={dummyCategoryStyles}              />              <div className="grid gap-6 md:grid-cols-2">                {items.slice(2, 6).map((item) => (                  <NewsCard                    key={item.id}                    item={item}                    variant="medium"                    href={`/news/${item.id}`}                    categoryStyles={dummyCategoryStyles}                  />                ))}              </div>            </div>            <aside className="lg:col-span-4">              <div className="rounded-2xl border border-border/50 bg-card p-4">                {items.slice(2, 6).map((item) => (                  <NewsCard                    key={item.id}                    item={item}                    variant="list"                    href={`/news/${item.id}`}                    categoryStyles={dummyCategoryStyles}                  />                ))}              </div>            </aside>          </div>        </div>      </TabsContent>       <TabsContent value="actions" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Click the bookmark or share buttons — they sit ABOVE the link          overlay (z-10) and don&apos;t trigger card navigation. Click anywhere          else on the card and you navigate normally.        </p>        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">          {items.slice(0, 3).map((item) => (            <NewsCard              key={item.id}              item={item}              variant="medium"              href={`/news/${item.id}`}              categoryStyles={dummyCategoryStyles}              actions={                <CardActions                  itemId={item.id}                  bookmarked={bookmarks.has(item.id)}                  onToggle={toggleBookmark}                />              }            />          ))}        </div>      </TabsContent>       <TabsContent value="editor" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          <strong>viewerMode=&quot;editor&quot;</strong> — kebab shows editor          actions (Edit / Publish / Schedule / Feature / Pin / Change visibility /          Change category / Mark sensitive / See analytics / Delete). Draft status          badge renders only in editor mode. Open the kebab on any card.        </p>        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">          {[editorsPickItem, draftItem, sponsoredItem].map((item) => (            <NewsCard              key={item.id}              item={item}              variant="medium"              href={`/news/${item.id}`}              categoryStyles={dummyCategoryStyles}              viewerMode="editor"              onEdit={log(`onEdit(${item.id})`)}              onDelete={log(`onDelete(${item.id})`)}              onPublish={log(`onPublish(${item.id})`)}              onSchedule={log(`onSchedule(${item.id})`)}              onFeature={log(`onFeature(${item.id})`)}              onPin={log(`onPin(${item.id})`)}              onChangeVisibility={log(`onChangeVisibility(${item.id})`)}              onChangeCategory={log(`onChangeCategory(${item.id})`)}              onMarkSensitive={log(`onMarkSensitive(${item.id})`)}              onSeeAnalytics={log(`onSeeAnalytics(${item.id})`)}              onShare={log(`onShare(${item.id})`)}              onBookmark={log(`onBookmark(${item.id})`)}            />          ))}        </div>      </TabsContent>       <TabsContent value="paywall" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Premium content gate. Excerpt + media blurred behind a Subscribe CTA.          Preview text shows above the gate. Open the console — clicking the CTA          fires <code>onRevealPaywall</code> as an analytics hook.        </p>        <div className="grid gap-6 md:grid-cols-2">          <NewsCard            item={paywalledItem}            variant="medium"            href={`/news/${paywalledItem.id}`}            categoryStyles={dummyCategoryStyles}            onRevealPaywall={log(`onRevealPaywall(${paywalledItem.id})`)}          />          <NewsCard            item={paywalledItem}            variant="large"            href={`/news/${paywalledItem.id}`}            categoryStyles={dummyCategoryStyles}            onRevealPaywall={log(`onRevealPaywall(${paywalledItem.id})`)}          />        </div>      </TabsContent>       <TabsContent value="sensitive" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Sensitive content gate — media-only blur with reveal button. Distinct          from paywall (different motivation). Lists content warnings when set.          Reveal is per-session; reset via the handle&apos;s{" "}          <code>reset(item)</code>. Small variant uses the gate&apos;s{" "}          <code>compact</code> mode (icon + tiny &quot;Show&quot; pill) since          its 96×96 thumb can&apos;t fit the full overlay.        </p>        <div className="space-y-6">          <NewsCard            item={sensitiveItem}            variant="medium"            href={`/news/${sensitiveItem.id}`}            categoryStyles={dummyCategoryStyles}            onRevealSensitive={log(`onRevealSensitive(${sensitiveItem.id})`)}          />          <NewsCard            item={sensitiveItem}            variant="small"            href={`/news/${sensitiveItem.id}`}            categoryStyles={dummyCategoryStyles}            onRevealSensitive={log(`onRevealSensitive(${sensitiveItem.id})`)}          />        </div>      </TabsContent>       <TabsContent value="quoted" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Analysis pieces quoting source articles render a nested compact          mini-card (uses <code>variant=&quot;small&quot;</code> internally          for the horizontal thumb-left, body-right layout). Recursion-stripped          (a quoted article&apos;s own <code>quotedArticle</code> is ignored),          and the inner card&apos;s paywall + sensitive gates are suppressed          so the quote stays a clean attribution. Renders in <code>medium</code>{" "}          + <code>list</code> variants only per the per-variant feature matrix.        </p>        <div className="space-y-6">          <NewsCard            item={quotingItem}            variant="medium"            href={`/news/${quotingItem.id}`}            categoryStyles={dummyCategoryStyles}            onQuotedClick={(q) => log(`onQuotedClick(${q.id})`)()}          />          <NewsCard            item={quotingItem}            variant="list"            href={`/news/${quotingItem.id}`}            categoryStyles={dummyCategoryStyles}            onQuotedClick={(q) => log(`onQuotedClick(${q.id})`)()}          />        </div>      </TabsContent>       <TabsContent value="engagement" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Light engagement counts — like / comment / bookmark / share chips with          handler-driven interactivity. For the news article{" "}          <strong>detail page</strong>, consumers pass{" "}          <code>renderEngagementCounts</code> to compose{" "}          <code>&lt;EngagementBar&gt;</code> in this slot — see the description          doc §6.2 for the integration pattern. <code>isLive</code> badge +          updated-N-ago sub-line surface live-blog state.        </p>        <div className="grid gap-6 md:grid-cols-2 lg:grid-cols-3">          {[breakingNewsItem, editorsPickItem, quotingItem].map((item) => (            <NewsCard              key={item.id}              item={item}              variant="medium"              href={`/news/${item.id}`}              categoryStyles={dummyCategoryStyles}              onLike={(id, nextLiked) => log(`onLike(${id}, ${nextLiked})`)()}              onCommentCountClick={(id) => log(`onCommentCountClick(${id})`)()}              onBookmark={(id, next) => log(`onBookmark(${id}, ${next})`)()}              onShare={(id) => log(`onShare(${id})`)()}            />          ))}        </div>      </TabsContent>    </Tabs>  );} 

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

  • v0.4.1 — `handle.openKebab()` actually opens the menu; the kebab is now controlled from the card root so the handle can reach whichever variant part renders it
  • 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