Civilization Card

Civilization overview with hero band, BCE/CE era timeline, key stats, achievements, and notable leaders.

Report a bug

Preview

Switch between light and dark to inspect the embedded Storybook preview.

Installation

pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/civilization-card.json

Storybook

Explore all variants, controls, and accessibility checks in the interactive Storybook playground.

View in Storybook

Code

import { Globe } from "lucide-react"; import type { ComponentPropsWithoutRef, ReactNode } from "react"; import { cn } from "../../lib/utils"; import { Badge } from "../badge/badge"; const FALLBACK_ERA_MIN_YEAR = -3000; const FALLBACK_ERA_SPAN_YEARS = 5000; const CIVILIZATION_COLOR_VARIANTS: Record< CivilizationCardColor, { gradient: string; ring: string } > = { amber: { gradient: "from-amber-500/20 to-amber-700/40", ring: "ring-amber-500/30", }, blue: { gradient: "from-blue-500/20 to-blue-700/40", ring: "ring-blue-500/30", }, emerald: { gradient: "from-emerald-500/20 to-emerald-700/40", ring: "ring-emerald-500/30", }, neutral: { gradient: "from-muted to-muted-foreground/10", ring: "ring-border", }, purple: { gradient: "from-purple-500/20 to-purple-700/40", ring: "ring-purple-500/30", }, red: { gradient: "from-red-500/20 to-red-700/40", ring: "ring-red-500/30", }, }; /** * Color theme for the {@link CivilizationCard} hero band. * * @public */ export type CivilizationCardColor = | "amber" | "blue" | "emerald" | "neutral" | "purple" | "red"; /** * Era span (start / end years) for {@link CivilizationCardProps}. * * Use positive integers for CE / negative integers for BCE. `end` may be * omitted when the civilization is extant. * * @public */ export type CivilizationCardEra = { /** End year. Negative for BCE; omit when extant. */ end?: number; /** Start year. Negative for BCE. */ start: number; }; /** * Localizable strings. * * @public */ export type CivilizationCardLabels = { /** Heading above the achievements list. Defaults to `"Achievements"`. */ achievements?: string; /** Caption for the capital row. Defaults to `"Capital"`. */ capital?: string; /** Caption for the duration stat. Defaults to `"Duration"`. */ duration?: string; /** Heading above the leaders list. Defaults to `"Notable leaders"`. */ leaders?: string; /** Caption for the peak population stat. Defaults to `"Peak population"`. */ peakPopulation?: string; /** Aria-label on the era timeline bar. Defaults to `"Era timeline"`. */ timeline?: string; }; /** * Props for {@link CivilizationCard}. * * @public */ export type CivilizationCardProps = { /** Notable achievements / cultural contributions. */ achievements?: ReactNode[]; /** Optional primary CTA href. Renders the card as a link card when set. */ actionHref?: string; /** Optional capital city. */ capital?: ReactNode; /** Color theme for the hero band. Defaults to `"neutral"`. */ color?: CivilizationCardColor; /** Era span. */ era?: CivilizationCardEra; /** Optional hero image src. Falls back to a globe icon. */ image?: string; /** Localizable captions. */ labels?: CivilizationCardLabels; /** Notable leaders. */ leaders?: ReactNode[]; /** Display name. */ name: ReactNode; /** Optional peak population stat (string). */ peakPopulation?: ReactNode; /** Optional geographic region. */ region?: ReactNode; } & ComponentPropsWithoutRef<"article">; const DEFAULT_LABELS = { achievements: "Achievements", capital: "Capital", duration: "Duration", leaders: "Notable leaders", peakPopulation: "Peak population", timeline: "Era timeline", } as const satisfies Required<CivilizationCardLabels>; function formatEraYear(year: number): string { if (year < 0) return `${Math.abs(year).toString()} BCE`; return `${year.toString()} CE`; } function formatEra(era: CivilizationCardEra): string { const start = formatEraYear(era.start); if (era.end === undefined) return `${start} – present`; return `${start}${formatEraYear(era.end)}`; } function getDuration(era: CivilizationCardEra | undefined): string | undefined { if (!era) return undefined; const end = era.end ?? new Date().getFullYear(); const years = end - era.start; if (years <= 0) return undefined; return `${years.toString()} years`; } type HeroProps = { color: CivilizationCardColor; image?: string; imageAlt?: string; }; function CivilizationHero({ color, image, imageAlt }: HeroProps): ReactNode { const palette = CIVILIZATION_COLOR_VARIANTS[color]; return ( <div className={cn( "relative h-32 w-full overflow-hidden rounded-t-2xl bg-gradient-to-br", palette.gradient, )} > {image ? ( <img alt={imageAlt ?? ""} className="h-full w-full object-cover mix-blend-multiply" src={image} /> ) : ( <div className="flex h-full w-full items-center justify-center text-muted-foreground/50"> <Globe aria-hidden="true" className="size-12" /> </div> )} </div> ); } type EraTimelineProps = { era: CivilizationCardEra; label: string; }; function getEraBarGeometry(era: CivilizationCardEra): { left: number; width: number; } { const end = era.end ?? new Date().getFullYear(); const min = Math.min(era.start, FALLBACK_ERA_MIN_YEAR); const range = Math.max(end, min + FALLBACK_ERA_SPAN_YEARS) - min || 1; const left = ((era.start - min) / range) * 100; const width = Math.max(((end - era.start) / range) * 100, 1); return { left, width }; } function EraTimeline({ era, label }: EraTimelineProps): ReactNode { const eraLabel = formatEra(era); const { left, width } = getEraBarGeometry(era); return ( <div aria-label={`${label}: ${eraLabel}`} className="flex flex-col gap-1" role="img" > <span className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> {eraLabel} </span> <div className="relative h-1.5 w-full rounded-full bg-muted"> <span className="absolute h-full rounded-full bg-primary" style={{ left: `${left.toString()}%`, width: `${width.toString()}%` }} /> </div> </div> ); } type StatsProps = { capital?: ReactNode; capitalCaption: string; durationCaption: string; durationValue?: string; peakCaption: string; peakPopulation?: ReactNode; }; function CivilizationStats({ capital, capitalCaption, durationCaption, durationValue, peakCaption, peakPopulation, }: StatsProps): ReactNode { const items: { caption: string; value: ReactNode }[] = []; if (capital) items.push({ caption: capitalCaption, value: capital }); if (peakPopulation) { items.push({ caption: peakCaption, value: peakPopulation }); } if (durationValue) { items.push({ caption: durationCaption, value: durationValue }); } if (items.length === 0) return null; return ( <dl className="grid grid-cols-2 gap-x-3 gap-y-2 text-sm"> {items.map((item) => ( <div className="flex flex-col" key={item.caption}> <dt className="text-xs font-medium uppercase tracking-wide text-muted-foreground"> {item.caption} </dt> <dd className="font-medium text-foreground">{item.value}</dd> </div> ))} </dl> ); } type ListBlockProps = { emptyHidden?: boolean; heading: string; items: ReactNode[]; variant: "badge" | "list"; }; function CivilizationListBlock({ heading, items, variant, }: ListBlockProps): ReactNode { if (items.length === 0) return null; return ( <div className="flex flex-col gap-2"> <h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground"> {heading} </h4> {variant === "badge" ? ( <div className="flex flex-wrap gap-1.5"> {items.map((item, index) => ( <Badge key={`${heading}-${index.toString()}`} variant="secondary"> {item} </Badge> ))} </div> ) : ( <ul className="flex flex-col gap-1 text-sm text-foreground"> {items.map((item, index) => ( <li className="leading-tight" key={`${heading}-${index.toString()}`} > {item} </li> ))} </ul> )} </div> ); } /** * Overview card for historical civilizations: hero band with optional image * + color theme, era timeline (BCE / CE / present), key stats, achievement * chips, notable leaders, and an optional follow-up link. * * @example * ```tsx * <CivilizationCard * name="Roman Empire" * era={{ start: -27, end: 476 }} * region="Mediterranean" * capital="Rome" * peakPopulation="70 million" * color="red" * achievements={["Aqueducts", "Roads", "Law", "Architecture"]} * leaders={["Augustus", "Trajan", "Marcus Aurelius"]} * actionHref="/civilizations/rome" * /> * ``` * * @public */ type CivilizationBodyProps = { achievements?: ReactNode[]; actionHref?: string; capital?: ReactNode; era?: CivilizationCardEra; labels: Required<CivilizationCardLabels>; leaders?: ReactNode[]; name: ReactNode; peakPopulation?: ReactNode; region?: ReactNode; }; function CivilizationBody({ achievements, actionHref, capital, era, labels, leaders, name, peakPopulation, region, }: CivilizationBodyProps): ReactNode { const durationValue = getDuration(era); return ( <div className="flex flex-col gap-4 p-5"> <header className="flex flex-col gap-1"> <h3 className="text-lg font-semibold leading-tight tracking-tight"> {name} </h3> {region ? ( <p className="text-sm text-muted-foreground">{region}</p> ) : null} </header> {era ? <EraTimeline era={era} label={labels.timeline} /> : null} <CivilizationStats capital={capital} capitalCaption={labels.capital} durationCaption={labels.duration} durationValue={durationValue} peakCaption={labels.peakPopulation} peakPopulation={peakPopulation} /> {achievements && achievements.length > 0 ? ( <CivilizationListBlock heading={labels.achievements} items={achievements} variant="badge" /> ) : null} {leaders && leaders.length > 0 ? ( <CivilizationListBlock heading={labels.leaders} items={leaders} variant="list" /> ) : null} {actionHref ? ( <a className="text-sm font-medium text-primary underline-offset-4 hover:underline" href={actionHref} > Explore → </a> ) : null} </div> ); } export const CivilizationCard = ({ ref, ...props }: CivilizationCardProps & { ref?: React.Ref<HTMLElement> }) => { const { achievements, actionHref, capital, className, color = "neutral", era, image, labels, leaders, name, peakPopulation, region, ...rest } = props; const resolvedLabels = { ...DEFAULT_LABELS, ...labels }; const palette = CIVILIZATION_COLOR_VARIANTS[color]; const altName = typeof name === "string" ? name : undefined; return ( <article className={cn( "flex flex-col overflow-hidden rounded-2xl border bg-background text-foreground shadow-sm ring-1", palette.ring, className, )} ref={ref} {...rest} > <CivilizationHero color={color} image={image} imageAlt={altName} /> <CivilizationBody achievements={achievements} actionHref={actionHref} capital={capital} era={era} labels={resolvedLabels} leaders={leaders} name={name} peakPopulation={peakPopulation} region={region} /> </article> ); }; CivilizationCard.displayName = "CivilizationCard"; /** * Props for {@link CivilizationComparison}. * * @public */ export type CivilizationComparisonProps = ComponentPropsWithoutRef<"div">; /** * Side-by-side comparison container for {@link CivilizationCard}s. Renders * children in a responsive grid (single column on mobile, two columns on * `md`, three on `lg`). * * @public */ export const CivilizationComparison = ({ children, className, ref, ...rest }: CivilizationComparisonProps & { ref?: React.Ref<HTMLDivElement> }) => { return ( <div className={cn( "grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-3", className, )} ref={ref} {...rest} > {children} </div> ); }; CivilizationComparison.displayName = "CivilizationComparison";

Dependencies

  • @vllnt/ui@^0.3.0
  • lucide-react