Civilization Card
Civilization overview with hero band, BCE/CE era timeline, key stats, achievements, and notable leaders. Install Civilization Card from the VLLNT UI registry with the shadcn CLI.
Civilization overview with hero band, BCE/CE era timeline, key stats, achievements, and notable leaders. Part of the educational family in VLLNT UI, it ships as a machine-readable registry entry — copy the source directly into your app with the shadcn CLI and own it, no runtime dependency on a component library.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
Add Civilization Card to your project with the shadcn CLI. The source lands in your codebase, ready to adapt:
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/civilization-card.jsonSource
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";
Stories
Explore every variant and state in the interactive Storybook:
Preview
Switch between light and dark to inspect the embedded Storybook preview.