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

Add -fixtures for dummy data:

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

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

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