Skip to content
ilinxa/pro-ui

Project Card

alphav0.3.0

Project and case-study card with editorial status states and grid or feature layouts — overlay links and soft-failure item handling.

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

Context

Use when listing portfolio / case-study / completed-projects cards on a public page or embedded business-profile widget. The grid variant fits magazine grids (compose with magazine-layout + filter-bar + page-hero for the full page assembly with zero new code); the feature variant fits embedded mosaic widgets (designed for the future bento-grid-01 layout). Status is editorial — set on the data object by an editor, NOT derived from a clock; differs from event-card's time-window kernel by design. The public PROJECT_STATUS_CONFIG export lets consumers build status legends, count summaries, and filter rows that share the same color / label vocabulary as the card. Migration origin: kasder kas-social-front-v0 ProjectCard.tsx + BusinessProjectsSection.tsx.

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/project-card

Add -fixtures for dummy data:

pnpm dlx shadcn@latest add @ilinxa/project-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

Grid variant — vertical image-on-top, hover-reveal "View details" CTA, lift-on-hover. Compose with magazine-layout for the public projects-page assembly (see usage notes).

Fikirtepe Urban RenewalIn progressUrban Renewal

Fikirtepe Urban Renewal

One of Istanbul's largest urban renewal projects — 15,000 housing units modernised across the Fikirtepe district.

  • Istanbul, Kadıköy
  • 2023
İzmir Earthquake HousingCompletedDisaster Management

İzmir Earthquake Housing

Earthquake-resistant modern housing complex built in the aftermath of the 2020 İzmir earthquake.

  • İzmir, Bayraklı
  • 2022
Ankara Green City InitiativeIn progressSustainable Development

Ankara Green City Initiative

Net-zero carbon-footprint living district powered by renewable energy and integrated mobility.

  • Ankara, Etimesgut
  • 2024
Bursa Historic Quarter PreservationCompletedHistoric Preservation

Bursa Historic Quarter Preservation (Featured project)

Restoration of the UNESCO World Heritage historical centre of Bursa, balancing daily life with preservation.

  • Bursa, Osmangazi
  • 2021
Samsun Coastline RedesignIn progressCoastal Development

Samsun Coastline Redesign

Modern, accessible public-space design along the Black Sea coastline, integrating recreation and ecology.

  • Samsun, Atakum
  • 2022
PlannedUrban Renewal

Mersin Port District Renewal

Conversion of the historic port district into a tourism and culture hub — currently in detailed-planning phase.

  • Mersin, Akdeniz
  • 2025

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 { ProjectCard } from "./project-card";import {  dummyCategoryStyles,  dummyProjects,  dummyTrCategoryStyles,  dummyTrLabels,  dummyTrProjects,} from "./dummy-data";import type { ProjectCardItem } from "./types"; const HREF_BASE = "/projects"; function makeHref(project: ProjectCardItem) {  return `${HREF_BASE}/${project.id}`;} function ActionsCluster({  projectId,  saved,  onToggle,}: {  projectId: string;  saved: boolean;  onToggle: (id: string) => void;}) {  return (    <div className="flex gap-1.5">      <Button        variant="secondary"        size="sm"        onClick={(e) => {          e.preventDefault();          e.stopPropagation();          onToggle(projectId);        }}        aria-label={saved ? "Remove from saved projects" : "Save project"}      >        {saved ? (          <BookmarkCheck aria-hidden="true" className="size-4" />        ) : (          <Bookmark aria-hidden="true" className="size-4" />        )}      </Button>      <Button        variant="secondary"        size="sm"        onClick={(e) => {          e.preventDefault();          e.stopPropagation();        }}        aria-label="Share project"      >        <Share2 aria-hidden="true" className="size-4" />      </Button>    </div>  );} const featuredProject = dummyProjects.find((p) => p.featured)!;const ongoingProject = dummyProjects.find(  (p) => p.status === "ongoing" && !p.featured,)!; /** * Bento-pattern className per index-within-batch-of-5 (mirrors kasder * `getLgPattern`). Inline preview of what `bento-grid-01` will eventually own. */function bentoClassFor(index: number, batchSize: number): string {  const indexInBatch = index % 5;  switch (batchSize) {    case 1:      return "lg:col-span-3 lg:row-span-2";    case 2:      return indexInBatch === 0        ? "lg:col-span-2 lg:row-span-1"        : "lg:col-span-1 lg:row-span-1";    case 3:      if (indexInBatch === 2) return "lg:col-span-2 lg:row-span-2";      return "lg:col-span-1 lg:row-span-1";    case 4:      if (indexInBatch === 0) return "lg:col-span-1 lg:row-span-2";      if (indexInBatch < 3) return "lg:col-span-1 lg:row-span-1";      return "lg:col-span-2 lg:row-span-1";    case 5:    default:      if (indexInBatch === 0) return "lg:col-span-2 lg:row-span-1";      return "lg:col-span-1 lg:row-span-1";  }} export default function ProjectCardDemo() {  const [saved, setSaved] = useState<Set<string>>(() => new Set());   const toggleSaved = (id: string) =>    setSaved((prev) => {      const next = new Set(prev);      if (next.has(id)) {        next.delete(id);      } else {        next.add(id);      }      return next;    });   // Bento layout: process projects in batches of 5  const bentoBatches: { items: ProjectCardItem[]; batchSize: number }[] = [];  for (let i = 0; i < dummyProjects.length; i += 5) {    const batch = dummyProjects.slice(i, i + 5);    bentoBatches.push({ items: batch, batchSize: batch.length });  }   return (    <Tabs defaultValue="grid" className="w-full">      <SwipeTabsList>        <TabsTrigger value="grid">Grid</TabsTrigger>        <TabsTrigger value="feature">Feature (bento)</TabsTrigger>        <TabsTrigger value="featured">Featured</TabsTrigger>        <TabsTrigger value="localized">Localized (TR)</TabsTrigger>        <TabsTrigger value="actions">Actions slot</TabsTrigger>      </SwipeTabsList>       {/* 1. Grid — vertical image-on-top, 1/2/3-col responsive */}      <TabsContent value="grid" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Grid variant — vertical image-on-top, hover-reveal &quot;View          details&quot; CTA, lift-on-hover. Compose with{" "}          <code className="text-xs px-1.5 py-0.5 rounded bg-muted">            magazine-layout          </code>{" "}          for the public projects-page assembly (see usage notes).        </p>        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">          {dummyProjects.map((project) => (            <ProjectCard              key={project.id}              project={project}              variant="grid"              href={makeHref(project)}              categoryStyles={dummyCategoryStyles}            />          ))}        </div>      </TabsContent>       {/* 2. Feature (bento) — full-bleed background, no hover-CTA */}      <TabsContent value="feature" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Feature variant — full-bleed image background, white-on-dark text,          no hover-CTA. Designed to live inside{" "}          <code className="text-xs px-1.5 py-0.5 rounded bg-muted">            bento-grid-01          </code>{" "}          (deferred). Until that ships, the consumer drives sizing via          inline <code className="text-xs px-1.5 py-0.5 rounded bg-muted">            lg:col-span-X lg:row-span-Y          </code>{" "}          on the card&apos;s <code className="text-xs px-1.5 py-0.5 rounded bg-muted">            className          </code>.        </p>        <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 auto-rows-[180px] gap-4">          {bentoBatches.flatMap(({ items, batchSize }) =>            items.map((project, indexInBatch) => (              <ProjectCard                key={project.id}                project={project}                variant="feature"                href={makeHref(project)}                categoryStyles={dummyCategoryStyles}                className={bentoClassFor(indexInBatch, batchSize)}              />            )),          )}        </div>      </TabsContent>       {/* 3. Featured — grid + feature side-by-side comparison */}      <TabsContent value="featured" className="mt-6">        <div className="space-y-8">          <div>            <p className="mb-3 text-sm text-muted-foreground">              Grid variant — top accent border + star prefix on title.            </p>            <div className="grid md:grid-cols-2 gap-6">              <ProjectCard                project={featuredProject}                variant="grid"                href={makeHref(featuredProject)}                categoryStyles={dummyCategoryStyles}              />              <ProjectCard                project={ongoingProject}                variant="grid"                href={makeHref(ongoingProject)}                categoryStyles={dummyCategoryStyles}              />            </div>          </div>          <div>            <p className="mb-3 text-sm text-muted-foreground">              Feature variant — inset ring + star prefix on title.            </p>            <div className="grid md:grid-cols-2 gap-4 auto-rows-[220px]">              <ProjectCard                project={featuredProject}                variant="feature"                href={makeHref(featuredProject)}                categoryStyles={dummyCategoryStyles}              />              <ProjectCard                project={ongoingProject}                variant="feature"                href={makeHref(ongoingProject)}                categoryStyles={dummyCategoryStyles}              />            </div>          </div>        </div>      </TabsContent>       {/* 4. Localized (TR) */}      <TabsContent value="localized" className="mt-6">        <p className="mb-4 text-sm text-muted-foreground">          Turkish project data + labels + category-style map. Mirrors kasder          defaults end-to-end; proves the full i18n surface.        </p>        <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">          {dummyTrProjects.map((project) => (            <ProjectCard              key={project.id}              project={project}              variant="grid"              href={makeHref(project)}              categoryStyles={dummyTrCategoryStyles}              labels={dummyTrLabels}            />          ))}        </div>      </TabsContent>       {/* 5. Actions slot + custom href */}      <TabsContent value="actions" className="mt-6">        <div className="space-y-2">          <p className="text-sm text-muted-foreground">            Action buttons stop propagation — clicks on Save / Share don&apos;t            navigate. The rest of the card surface still does. Custom{" "}            <code className="text-xs px-1.5 py-0.5 rounded bg-muted">              getHref            </code>{" "}            routes via{" "}            <code className="text-xs px-1.5 py-0.5 rounded bg-muted">              /portfolio/{"{id}"}            </code>{" "}            instead of the default{" "}            <code className="text-xs px-1.5 py-0.5 rounded bg-muted">              /projects/{"{id}"}            </code>            . Currently saved:{" "}            <span className="font-mono text-xs">              {saved.size > 0 ? Array.from(saved).join(", ") : "(none)"}            </span>          </p>          <div className="grid md:grid-cols-2 lg:grid-cols-3 gap-6">            {dummyProjects.slice(0, 3).map((project) => (              <ProjectCard                key={project.id}                project={project}                variant="grid"                getHref={(p) => `/portfolio/${p.id}`}                categoryStyles={dummyCategoryStyles}                actions={                  <ActionsCluster                    projectId={project.id}                    saved={saved.has(project.id)}                    onToggle={toggleSaved}                  />                }              />            ))}          </div>        </div>      </TabsContent>    </Tabs>  );} 

Usage

When to use

Reach for ProjectCard when you need a project / case-study / portfolio preview with a 3-state editorial status (completed / in-progress / planned). Two layouts: variant="grid"(vertical image-on-top with a hover-reveal "View details" CTA) and variant="feature" (full-bleed image background, white-on-dark text, designed for embedded mosaic widgets).

Minimal example

import { ProjectCard } from "@/components/project-card";

<ProjectCard
  project={{
    id: "fikirtepe-renewal",
    title: "Fikirtepe Urban Renewal",
    category: "Urban Renewal",
    location: "Istanbul, Kadıköy",
    year: "2023",
    image: "/cover.jpg",
    description: "15,000 housing units modernised across the district.",
    status: "ongoing",
  }}
  variant="grid"
  href="/projects/fikirtepe-renewal"
/>;

Two variants

  • grid— vertical image-on-top with status pill (top-left), category pill (top-right), primary-tinted gradient overlay, hover-reveal "View details" CTA, lift-on-hover. Renders well in 1/2/3-column responsive grids — pair with magazine-layout.
  • feature — full-bleed image background, white-on-dark content overlaid at the bottom (title + 2-line description). Status pill top-right, category top-left. NO meta row, NO hover-CTA — denser and quieter than grid. Designed for embedded widgets inside bento-grid-01 (deferred) or any consumer-driven sized parent.

Three statuses (editorial — not derived)

Status is set on the data object by an editor. The card does NOT derive status from a date or completion-percentage — projects don't have a time-window kernel. The status drives the pill color + label via PROJECT_STATUS_CONFIG:

  • completed — primary lime pill. Project is done; reads celebratory.
  • ongoing — chart-3 teal pill. Project is currently active. (Originally proposed bg-accent but pro-ui's --accent is a near-white surface token, not a brand color — teal is the readable middle ground.)
  • planned — muted-grey pill with subtle border. Project is scheduled but not yet started; reads quiet, not attention-grabbing.

Public helper kernel (without rendering the card)

PROJECT_STATUS_CONFIG + ProjectStatus are exported alongside the card. Consumers can read the same color / label map for status legends, filter rows, count summaries — without rendering a card. Pure data, server-component-importable, tree-shakeable.

import {
  PROJECT_STATUS_CONFIG,
  type ProjectStatus,
} from "@/components/project-card";

function StatusSummary({ projects }: { projects: { status: ProjectStatus }[] }) {
  const counts = projects.reduce<Record<ProjectStatus, number>>(
    (acc, p) => ({ ...acc, [p.status]: (acc[p.status] ?? 0) + 1 }),
    { completed: 0, ongoing: 0, planned: 0 },
  );
  return (
    <div className="flex gap-3">
      {(Object.keys(counts) as ProjectStatus[]).map((s) => (
        <span key={s} className={`px-2 py-0.5 rounded-full text-xs ${PROJECT_STATUS_CONFIG[s].className}`}>
          {PROJECT_STATUS_CONFIG[s].label}: {counts[s]}
        </span>
      ))}
    </div>
  );
}

Polymorphic linking (NextLink, RemixLink, react-router Link)

Pass linkComponent to swap the underlying anchor:

import NextLink from "next/link";
import { ProjectCard } from "@/components/project-card";

<ProjectCard
  project={project}
  variant="grid"
  href={`/projects/${project.id}`}
  linkComponent={NextLink}
/>;

href precedence: getHref(project) wins over href wins over project.href wins over "#" fallback.

Per-category theming (categoryStyles)

Default behavior renders a universal Building2 icon + a white-translucent chip on top of the image. Override per-category via the categoryStyles map (default: empty). Each entry can provide a className, an icon, or both:

import { Trees, Shield } from "lucide-react";

<ProjectCard
  project={project}
  variant="grid"
  href={`/projects/${project.id}`}
  // Solid backgrounds (90% opacity) for over-image legibility — light tints
  // (bg-X/15 text-X) read poorly over photographic content. Use solid fills with
  // text-white or text-{token}-foreground.
  categoryStyles={{
    "Sustainable Development": {
      className: "bg-chart-3/90 text-white",
      icon: Trees,
    },
    "Disaster Management": {
      className: "bg-warning/90 text-warning-foreground",
      icon: Shield,
    },
  }}
/>;

Internationalization (labels)

Pass a partial labels object — only the keys you want to override:

<ProjectCard
  project={project}
  variant="grid"
  href={`/projects/${project.id}`}
  labels={{
    completed: "Tamamlandı",
    ongoing: "Devam Ediyor",
    planned: "Planlanan",
    viewDetails: "Detayları Gör",
    featuredAriaLabel: "Öne çıkan proje",
  }}
/>;

Actions slot (overlay-link pattern)

Drop interactive children into actions — they sit at z-10 over the link overlay. When supplied, the category pill yields its top-right slot to actions and moves to bottom-right (grid) OR the status pill yields and stacks under the category pill at top-left (feature). Each nested button MUST call e.stopPropagation()or the card's link will fire too.

<ProjectCard
  project={project}
  variant="grid"
  href={`/projects/${project.id}`}
  actions={
    <button
      onClick={(e) => {
        e.preventDefault();
        e.stopPropagation();
        toggleBookmark(project.id);
      }}
      aria-label="Bookmark"
    >
      <Bookmark className="size-4" />
    </button>
  }
/>;

Composing the public projects page (with already-shipped pro-comps)

The card is a leaf. Compose the full page from already-shipped pro-comps — zero new code beyond the data layer:

import { useState } from "react";
import NextLink from "next/link";
import { Building2 } from "lucide-react";
import { PageHero } from "@/components/page-hero";
import { FilterBar } from "@/components/filter-bar";
import {
  MagazineLayout,
  useMagazineFilter,
} from "@/components/magazine-layout";
import { ProjectCard } from "@/components/project-card";

export default function ProjectsPage({ allProjects }) {
  const [category, setCategory] = useState<string | null>(null);
  const filtered = useMagazineFilter({
    items: allProjects,
    pageSize: 6,
    filterPredicate: (p) => !category || p.category === category,
    simulatedLoadingMs: 500,
  });

  return (
    <MagazineLayout
      hero={
        <PageHero
          badge="Projects"
          badgeIcon={Building2}
          title="Transformations We've Delivered"
          titleHighlight="Across Türkiye"
          description="Urban renewal, disaster management, and sustainable-development projects."
        />
      }
      filterBar={
        <FilterBar
          categories={["Urban Renewal", "Disaster Management", "Sustainable Development"]}
          category={category}
          onCategoryChange={setCategory}
          hideSearch
          hideDateRange
        />
      }
      displayedItems={filtered.displayedItems}
      hasMore={filtered.hasMore}
      isLoading={filtered.isLoading}
      onLoadMore={filtered.loadMore}
      renderItem={(project) => (
        <ProjectCard
          key={project.id}
          project={project}
          variant="grid"
          href={`/projects/${project.id}`}
          linkComponent={NextLink}
        />
      )}
    />
  );
}

feature variant sizing contract

The feature variant uses absolute inset-0 for its image and requires a sized parent — the card does NOT impose a default aspect ratio. Without a sized container, the card collapses to zero height. The future bento-grid-01 (layout) will absorb this responsibility; until then, drive sizing with auto-rows-[180px] on the grid container or pass className="lg:col-span-2 lg:row-span-1" per card.

Soft-failure on missing fields

  • image empty string ⇒ bg-muted placeholder with centered Building2 icon. No broken-image icon.
  • imageAlt undefined ⇒ falls back to title.
  • location / year undefined ⇒ that meta cell omitted (grid). Both undefined ⇒ entire <ul> not rendered.
  • feature variant never renders the meta row regardless (matches source DNA).
  • No href / getHref / project.href ⇒ link falls to "#".

Accessibility

  • The wrapping link uses aria-labelledby pointing to the useId()-generated <h3>id. The link's accessible name is the title — not a flattened blob.
  • Override the link's accessible name explicitly via ariaLabel when needed (e.g. translated titles).
  • Status differentiated by color AND text — the label is always rendered (not icon-only). Color-blind safe.
  • All hover transforms / opacity transitions gated via motion-safe:. Reduced-motion users see static cards.
  • Featured projects render a <Star> icon prefix on the title (aria-hidden) plus an sr-only announcement (labels.featuredAriaLabel).
  • ArrowRight on the grid hover-CTA is mirrored via rtl:rotate-180 for right-to-left locales.

Performance

  • Component is wrapped in React.memo at the export. Pass stable project references from your data layer to keep memo effective.
  • <img> uses loading="lazy" by default — override via the loading prop for above-fold cards.
  • The status kernel (PROJECT_STATUS_CONFIG) is pure data with zero React imports — safe to import in Server Components.

Features

  • 3-state editorial status (completed / ongoing / planned) — set on data, not derived
  • 2 visual variants — grid (vertical image-on-top, hover-reveal CTA, lift-on-hover) and feature (full-bleed image background, white-on-dark, no hover-CTA)
  • Public PROJECT_STATUS_CONFIG export — pure data, server-component-importable
  • Polymorphic root via linkComponent (works with NextLink / RemixLink / etc.)
  • Overlay-link pattern with optional actions slot for nested interactives
  • categoryStyles map — per-category className + icon override (default: universal Building2 + neutral chip)
  • Soft-failure on optional fields (location / year / image — all gracefully omitted)
  • Image fallback — bg-muted block + Building2 icon when image is empty (no broken-image icon)
  • Featured treatment — top accent border (grid) / inset ring (feature) + star title prefix
  • href precedence chain — getHref(project) > href > project.href > '#'
  • Zero new design-system tokens, zero new shadcn primitives, zero new peer deps
  • WCAG 2.1 AA — aria-labelledby + useId, motion-safe gating, color-AND-text status differentiation

Tags

project-cardprojectsportfoliocase-studystatuscard

Dependencies

npm peer deps: lucide-react@^1.11.0