Era Comparison
Side-by-side comparison of historical eras with domain rows, color-themed columns, highlights, and figure chips.
Preview
Switch between light and dark to inspect the embedded Storybook preview.
Installation
pnpm dlx shadcn@latest add https://ui.vllnt.ai/r/era-comparison.jsonbash
Storybook
Explore all variants, controls, and accessibility checks in the interactive Storybook playground.
View in Storybook3 stories available:
Code
"use client";
import {
type AnchorHTMLAttributes,
type ComponentPropsWithoutRef,
createContext,
forwardRef,
type ReactNode,
useContext,
useMemo,
} from "react";
import { cn } from "../../lib/utils";
/**
* Color theme for an {@link EraColumn} accent strip.
*
* @public
*/
export type EraColor =
| "amber"
| "blue"
| "emerald"
| "neutral"
| "purple"
| "red"
| "rose";
const ERA_PALETTE: Record<EraColor, { accent: string; chip: string }> = {
amber: {
accent: "bg-amber-500",
chip: "bg-amber-500/15 text-amber-700 dark:text-amber-300",
},
blue: {
accent: "bg-blue-500",
chip: "bg-blue-500/15 text-blue-700 dark:text-blue-300",
},
emerald: {
accent: "bg-emerald-500",
chip: "bg-emerald-500/15 text-emerald-700 dark:text-emerald-300",
},
neutral: {
accent: "bg-muted-foreground/40",
chip: "bg-muted text-muted-foreground",
},
purple: {
accent: "bg-purple-500",
chip: "bg-purple-500/15 text-purple-700 dark:text-purple-300",
},
red: {
accent: "bg-red-500",
chip: "bg-red-500/15 text-red-700 dark:text-red-300",
},
rose: {
accent: "bg-rose-500",
chip: "bg-rose-500/15 text-rose-700 dark:text-rose-300",
},
};
type EraColumnContextValue = {
color: EraColor;
};
const EraColumnContext = createContext<EraColumnContextValue>({
color: "neutral",
});
/**
* Hook for reading the surrounding {@link EraColumn}'s color theme. Useful
* for custom children that want to match the column accent.
*
* @public
*/
export function useEraColumnColor(): EraColor {
return useContext(EraColumnContext).color;
}
/**
* Props for {@link EraComparison}.
*
* @public
*/
export type EraComparisonProps = ComponentPropsWithoutRef<"section">;
/**
* Side-by-side comparison of historical eras. Lays out {@link EraColumn}
* children in a responsive grid (1 col on mobile → 2 col on `md` → 3 col
* on `xl`). Composes {@link Badge}.
*
* @example
* ```tsx
* <EraComparison>
* <EraColumn name="Renaissance" period="1400–1600" color="amber">
* <EraDomain name="Art">
* <EraHighlight>Perspective painting, humanism</EraHighlight>
* <EraFigure name="Leonardo da Vinci" />
* </EraDomain>
* </EraColumn>
* <EraColumn name="Islamic Golden Age" period="800–1400" color="emerald">
* <EraDomain name="Science">
* <EraHighlight>Algebra, optics, astronomy</EraHighlight>
* <EraFigure name="Al-Khwarizmi" />
* </EraDomain>
* </EraColumn>
* </EraComparison>
* ```
*
* @public
*/
export const EraComparison = forwardRef<HTMLElement, EraComparisonProps>(
({ children, className, ...rest }, ref) => (
<section
className={cn(
"grid grid-cols-1 gap-4 md:grid-cols-2 xl:grid-cols-3",
className,
)}
ref={ref}
{...rest}
>
{children}
</section>
),
);
EraComparison.displayName = "EraComparison";
/**
* Props for {@link EraColumn}.
*
* @public
*/
export type EraColumnProps = {
/** Color theme. Drives the top accent strip + chip. Defaults to `"neutral"`. */
color?: EraColor;
/** Era display name. */
name: ReactNode;
/** Period label (e.g. `"1400–1600"`). */
period?: ReactNode;
/** Optional region label (e.g. `"Europe"`). */
region?: ReactNode;
} & ComponentPropsWithoutRef<"article">;
type ColumnHeaderProps = {
color: EraColor;
name: ReactNode;
period?: ReactNode;
region?: ReactNode;
};
function ColumnHeader({
color,
name,
period,
region,
}: ColumnHeaderProps): ReactNode {
const palette = ERA_PALETTE[color];
return (
<header className="flex flex-col gap-2">
<span
aria-hidden="true"
className={cn("h-1 w-12 rounded-full", palette.accent)}
/>
<div className="flex items-baseline justify-between gap-2">
<h3 className="text-base font-semibold tracking-tight text-foreground">
{name}
</h3>
{period ? (
<span
className={cn(
"rounded-full px-2 py-0.5 text-xs font-mono",
palette.chip,
)}
>
{period}
</span>
) : null}
</div>
{region ? (
<p className="text-xs text-muted-foreground">{region}</p>
) : null}
</header>
);
}
/**
* Single era column inside an {@link EraComparison}. Wraps a header (name,
* period, region) and the column body — typically a series of
* {@link EraDomain} sections.
*
* @public
*/
export const EraColumn = forwardRef<HTMLElement, EraColumnProps>(
(props, ref) => {
const {
children,
className,
color = "neutral",
name,
period,
region,
...rest
} = props;
const contextValue = useMemo<EraColumnContextValue>(
() => ({ color }),
[color],
);
return (
<EraColumnContext.Provider value={contextValue}>
<article
className={cn(
"flex flex-col gap-3 rounded-2xl border border-border bg-background p-4 shadow-sm",
className,
)}
data-color={color}
ref={ref}
{...rest}
>
<ColumnHeader
color={color}
name={name}
period={period}
region={region}
/>
<div className="flex flex-col gap-3">{children}</div>
</article>
</EraColumnContext.Provider>
);
},
);
EraColumn.displayName = "EraColumn";
/**
* Props for {@link EraDomain}.
*
* @public
*/
export type EraDomainProps = {
/** Domain display name (e.g. `"Art"`, `"Science"`). */
name: ReactNode;
} & ComponentPropsWithoutRef<"section">;
/**
* Domain row inside an {@link EraColumn}. Aligns across columns by
* convention — pass the same `name` in each column for parallel rows.
*
* @public
*/
export const EraDomain = forwardRef<HTMLElement, EraDomainProps>(
({ children, className, name, ...rest }, ref) => (
<section
className={cn("flex flex-col gap-2", className)}
data-domain={typeof name === "string" ? name : undefined}
ref={ref}
{...rest}
>
<h4 className="text-xs font-semibold uppercase tracking-wide text-muted-foreground">
{name}
</h4>
{children}
</section>
),
);
EraDomain.displayName = "EraDomain";
/**
* Props for {@link EraHighlight}.
*
* @public
*/
export type EraHighlightProps = ComponentPropsWithoutRef<"p">;
/**
* Single-line achievement note inside an {@link EraDomain}. Picks up the
* surrounding column's color theme automatically.
*
* @public
*/
export const EraHighlight = forwardRef<HTMLParagraphElement, EraHighlightProps>(
({ children, className, ...rest }, ref) => {
const color = useEraColumnColor();
const palette = ERA_PALETTE[color];
return (
<p
className={cn("rounded-md px-2 py-1 text-sm", palette.chip, className)}
ref={ref}
{...rest}
>
{children}
</p>
);
},
);
EraHighlight.displayName = "EraHighlight";
type AnchorPassthroughProps = Omit<
AnchorHTMLAttributes<HTMLAnchorElement>,
"children"
>;
/**
* Props for {@link EraFigure}.
*
* @public
*/
export type EraFigureProps = {
/** Anchor passthrough (e.g. `target`, `rel`). Forwarded with `href`. */
anchorProps?: AnchorPassthroughProps;
/** Optional anchor href. With this prop the chip renders as an `<a>`. */
href?: string;
/** Display name for the figure. */
name: ReactNode;
} & Omit<ComponentPropsWithoutRef<"span">, "children">;
/**
* Pill-shaped reference to a notable figure. With an `href`, the chip
* renders as a link.
*
* @public
*/
export const EraFigure = forwardRef<HTMLSpanElement, EraFigureProps>(
(props, ref) => {
const { anchorProps, className, href, name, ...rest } = props;
const color = useEraColumnColor();
const palette = ERA_PALETTE[color];
const baseClass = cn(
"inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium",
palette.chip,
className,
);
if (href) {
return (
<a
className={cn(baseClass, "underline-offset-4 hover:underline")}
href={href}
{...anchorProps}
>
{name}
</a>
);
}
return (
<span className={baseClass} ref={ref} {...rest}>
{name}
</span>
);
},
);
EraFigure.displayName = "EraFigure";
typescript