Market Treemap

Sector-style market heatmap using weighted tiles and signed performance colors.

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/market-treemap.json

Storybook

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

View in Storybook

2 stories available:

Code

import * as React from "react"; import { ArrowDownRight, ArrowUpRight } from "lucide-react"; import type { HeadingTag } from "../../lib/types"; import { cn } from "../../lib/utils"; export type MarketTreemapItem = { change: number; label: string; sector?: string; value: number; }; export type MarketTreemapProps = { /** Heading tag for the main "Market treemap" title. Defaults to `h2`. */ as?: HeadingTag; items: MarketTreemapItem[]; } & React.HTMLAttributes<HTMLDivElement>; function getSpan(value: number, maxValue: number) { const normalized = value / Math.max(maxValue, 1); if (normalized >= 0.7) { return "md:col-span-2 md:row-span-2"; } if (normalized >= 0.4) { return "md:col-span-2"; } return ""; } function getTone(change: number) { const isPositive = change >= 0; return { isPositive, tileClassName: isPositive ? "border-emerald-500/30 bg-emerald-500/10" : "border-rose-500/30 bg-rose-500/10", trendClassName: isPositive ? "text-emerald-600 dark:text-emerald-400" : "text-rose-600 dark:text-rose-400", }; } function MarketTreemapTile({ item, maxValue, }: { item: MarketTreemapItem; maxValue: number; }) { const tone = getTone(item.change); const TrendIcon = tone.isPositive ? ArrowUpRight : ArrowDownRight; return ( <article className={cn( "flex min-h-[120px] flex-col justify-between rounded-2xl border p-4 transition-transform duration-200 hover:-translate-y-0.5", tone.tileClassName, getSpan(item.value, maxValue), )} > <div className="space-y-2"> <div className="flex items-start justify-between gap-3"> <div> <p className="text-xs font-medium uppercase tracking-[0.24em] text-muted-foreground"> {item.sector ?? "Market"} </p> <h3 className="mt-1 text-xl font-semibold text-foreground"> {item.label} </h3> </div> <div className={cn( "inline-flex items-center gap-1 text-sm font-medium", tone.trendClassName, )} > <TrendIcon className="size-4" /> {item.change > 0 ? "+" : ""} {item.change.toFixed(2)}% </div> </div> </div> <div className="flex items-end justify-between gap-3 text-sm text-muted-foreground"> <span>Weight {item.value.toLocaleString()}</span> <span className="rounded-full bg-background/70 px-2 py-1 text-xs uppercase tracking-[0.2em] text-foreground"> {tone.isPositive ? "Advancing" : "Declining"} </span> </div> </article> ); } export const MarketTreemap = React.forwardRef< HTMLDivElement, MarketTreemapProps >(({ as: Heading = "h2", className, items, ...props }, reference) => { if (items.length === 0) { return null; } const maxValue = Math.max(...items.map((item) => item.value)); return ( <div className={cn( "rounded-2xl border border-border bg-card/80 p-4 shadow-sm", className, )} ref={reference} {...props} > <div className="mb-4 flex flex-wrap items-center justify-between gap-3"> <div> <p className="text-xs font-medium uppercase tracking-[0.28em] text-muted-foreground"> Sector heatmap </p> <Heading className="text-lg font-semibold text-foreground"> Market treemap </Heading> </div> <p className="text-sm text-muted-foreground"> Tile size maps market cap proxy; color reflects session change. </p> </div> <div className="grid auto-rows-[120px] grid-cols-1 gap-3 md:auto-rows-[120px] md:grid-cols-4"> {items.map((item) => ( <MarketTreemapTile item={item} key={item.label} maxValue={maxValue} /> ))} </div> </div> ); }); MarketTreemap.displayName = "MarketTreemap";

Dependencies

  • @vllnt/ui@^0.2.1