Era Comparison

Side-by-side comparison of historical eras with domain rows, color-themed columns, highlights, and figure chips. Install Era Comparison from the VLLNT UI registry with the shadcn CLI.

Report a bug

Side-by-side comparison of historical eras with domain rows, color-themed columns, highlights, and figure chips. 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 Era Comparison 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/era-comparison.json

Source

"use client"; import { type AnchorHTMLAttributes, type ComponentPropsWithoutRef, createContext, type ReactNode, type Ref, use, useMemo, } from "react"; import { cn } from "../../lib/utils"; function assignRef<T>(ref: Ref<T> | undefined, node: null | T): void { if (typeof ref === "function") { ref(node); } else if (ref) { ref.current = node; } } /** * 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 use(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 = ({ children, className, ref, ...rest }: EraComparisonProps & { ref?: React.Ref<HTMLElement> }) => ( <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 = ({ ref, ...props }: EraColumnProps & { ref?: React.Ref<HTMLElement> }) => { 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 = ({ children, className, name, ref, ...rest }: EraDomainProps & { ref?: React.Ref<HTMLElement> }) => ( <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 = ({ children, className, ref, ...rest }: EraHighlightProps & { ref?: React.Ref<HTMLParagraphElement> }) => { 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 = ({ ref, ...props }: EraFigureProps & { ref?: React.Ref<HTMLAnchorElement | HTMLSpanElement>; }) => { 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} ref={(node) => { assignRef(ref, node); }} {...anchorProps} > {name} </a> ); } return ( <span className={baseClass} ref={(node) => { assignRef(ref, node); }} {...rest} > {name} </span> ); }; EraFigure.displayName = "EraFigure";

Stories

Explore every variant and state in the interactive Storybook:

Preview

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

Dependencies

  • @vllnt/ui@^0.3.0