chartBarInteractive
A Charts block/component from shadcn/ui — clean-room reimplemented for Domphy (see methodology). Call chartBarInteractive() with no arguments for a working demo, or edit the code below live.
Props
| Prop | Type | Description |
|---|---|---|
title | string | — |
subtitle | string | — |
data | ChartBarDailyPoint[] | — |
initialSeries | SeriesKey | — |
desktopLabel | string | — |
desktopColor | ThemeColor | — |
mobileLabel | string | — |
mobileColor | ThemeColor | — |
Implementation notes
Mirrors the pre-existing chart-line-interactive.ts pattern: a header aside with two clickable stat tiles (label + locale-formatted running total over the FULL dataset, computed once) that swap which daily series (desktop/mobile) is plotted via a reactive toState<ChartOption> fed into chart(), with data-active styling on the selected tile (verified by the click-to-switch test). ~90 days of bars, horizontal split lines only, axisPointer:'shadow' cursor column, no footer (the header's tabs take over that role per spec). Two approximations: (1) switching series re-tweens via the same clip-path sweep-reveal used on mount rather than a true bar-height tween (no engine hook for animating bar geometry). (2) AxisLabelOption.formatter is declared on the type but never read by the axis renderer (verified against overlay/axes.ts — only the raw tick string is drawn), so short date labels ('Apr 1') are pre-formatted directly into the category array fed to xAxis.data instead of relying on that (inert) formatter hook; this is also flagged as a reusable finding in chart-bar-shared.ts since the pre-existing chart-line-interactive.ts file relies on the same non-functional formatter option for its own date axis.
Status: ported · Reference: shadcn/ui original
// shadcn/ui "chart-bar" (interactive recipe) — clean-room reimplementation.
//
// A wide, footer-less card whose header doubles as a two-option toggle:
// each tab shows a series name plus its full-dataset running total, and
// clicking a tab swaps which daily series is plotted as thin vertical bars.
// The x-axis shows abbreviated date labels (auto-thinned by the engine for
// density), only horizontal gridlines are drawn, and hovering shows a
// shaded cursor column behind the hovered day.
//
// Implemented purely from the block's public functional/visual spec — no
// upstream shadcn/ui source was viewed or copied.
import type { ChartOption, TooltipParams } from "@domphy/chart";
import { chart } from "@domphy/chart";
import type { DomphyElement, ElementNode, Listener } from "@domphy/core";
import { behavior, toState } from "@domphy/core";
import { themeColor, themeSpacing } from "@domphy/theme";
import { card, heading, paragraph, small } from "@domphy/ui";
import {
CHART_BAR_DAILY_DATA,
type ChartBarDailyPoint,
chartBarColorHex,
chartBarSeriesColor,
chartBarTooltipRow,
chartBarValueDomain,
} from "./chart-bar-shared.js";
type SeriesKey = "desktop" | "mobile";
// Class instance so cloneDescriptor/deepClone passes it by reference —
// a plain `{ current }` box would be cloned and sweepReveal would keep
// reading generation 2's empty copy.
class ChartFrameRef {
current: HTMLElement | null = null;
}
type RevealFrameProps = {
frame: ChartFrameRef;
onAttach?: () => void;
};
function attachRevealFrame(node: ElementNode, props: RevealFrameProps) {
const element = node.domElement as HTMLElement;
props.frame.current = element;
props.onAttach?.();
return {
update: (next: RevealFrameProps) => {
next.frame.current = element;
},
destroy: () => {
props.frame.current = null;
},
};
}
function escapeHtml(text: string): string {
return text
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
function formatLongDate(isoDate: string): string {
const date = new Date(`${isoDate}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return isoDate;
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
year: "numeric",
timeZone: "UTC",
}).format(date);
}
// @domphy/chart's axis renderer never reads `axisLabel.formatter` (verified
// against packages/chart/src/overlay/axes.ts — only the raw tick string is
// drawn) — so category tick text is pre-formatted here rather than supplied
// through that (currently inert) hook.
function formatShortDate(isoDate: string): string {
const date = new Date(`${isoDate}T00:00:00Z`);
if (Number.isNaN(date.getTime())) return isoDate;
return new Intl.DateTimeFormat("en-US", {
month: "short",
day: "numeric",
timeZone: "UTC",
}).format(date);
}
export interface ChartBarInteractiveProps {
title?: string;
subtitle?: string;
data?: ChartBarDailyPoint[];
initialSeries?: SeriesKey;
desktopLabel?: string;
/** Theme role (resolved at shift-9) or literal ramp hex. */
desktopColor?: string;
mobileLabel?: string;
/** Theme role (resolved at shift-9) or literal ramp hex. */
mobileColor?: string;
}
/**
* shadcn/ui "chart-bar" interactive recipe — a dense daily bar chart whose
* header stat tiles double as a series switcher. Call with no arguments for
* a fully working demo.
*/
function chartBarInteractive(
props: ChartBarInteractiveProps = {},
): DomphyElement<"div"> {
const {
title = "Bar Chart - Interactive",
subtitle = "Showing total visitors for the last 3 months",
data = CHART_BAR_DAILY_DATA,
initialSeries = "desktop",
desktopLabel = "Desktop",
// Upstream chartConfig: desktop=var(--chart-2), mobile=var(--chart-1) —
// two steps of the same monochrome blue ramp (default active: desktop).
desktopColor = chartBarSeriesColor(1).css,
mobileLabel = "Mobile",
mobileColor = chartBarSeriesColor(0).css,
} = props;
const seriesMeta: Record<SeriesKey, { label: string; color: string }> = {
desktop: { label: desktopLabel, color: desktopColor },
mobile: { label: mobileLabel, color: mobileColor },
};
// Category tick text is pre-formatted (see formatShortDate note above);
// the tooltip resolves the full long-form date from the SAME index
// against the original `data` array, independent of tick label content.
const categories = data.map((point) => formatShortDate(point.date));
const totals: Record<SeriesKey, number> = {
desktop: data.reduce((sum, point) => sum + point.desktop, 0),
mobile: data.reduce((sum, point) => sum + point.mobile, 0),
};
const [, domainMax] = chartBarValueDomain([
...data.map((p) => p.desktop),
...data.map((p) => p.mobile),
]);
const activeSeriesKey = toState<SeriesKey>(initialSeries);
const tooltipFormatter =
(swatchColor: string) =>
(parametersInput: TooltipParams | TooltipParams[]): string => {
const point = Array.isArray(parametersInput)
? parametersInput[0]
: parametersInput;
if (!point) return "";
const day = data[point.dataIndex];
const dateLabel = day ? formatLongDate(day.date) : "";
// The engine's param `color` follows its own multi-hue rotation palette
// — the swatch uses the active series' configured ramp color instead.
const swatch = `<span style="display:inline-block;width:8px;height:8px;border-radius:50%;background:${chartBarColorHex(swatchColor)};margin-right:6px;"></span>`;
return `<strong>${escapeHtml(dateLabel)}</strong>${chartBarTooltipRow(swatch, "Page Views", escapeHtml(String(point.value ?? "")))}`;
};
function buildOption(activeKey: SeriesKey): ChartOption {
const meta = seriesMeta[activeKey];
return {
grid: { left: 12, right: 12, top: 16, bottom: 28 },
xAxis: {
type: "category",
data: categories,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: true },
splitLine: { show: false },
},
yAxis: {
type: "value",
min: 0,
max: domainMax,
axisLine: { show: false },
axisTick: { show: false },
axisLabel: { show: false },
splitLine: { show: true },
},
tooltip: {
trigger: "axis",
axisPointer: { type: "shadow" },
formatter: tooltipFormatter(meta.color),
},
series: [
{
type: "bar",
name: meta.label,
color: meta.color,
data: data.map((point) => point[activeKey]),
},
],
};
}
// A plain State (not computed()) — @domphy/chart's chart() patch subscribes
// via `.addListener`, which only a real State instance exposes.
const optionState = toState<ChartOption>(buildOption(initialSeries));
const plotFrame = new ChartFrameRef();
function sweepReveal(): void {
const plotElement = plotFrame.current;
if (!plotElement || typeof plotElement.animate !== "function") return;
plotElement.animate(
[
{ clipPath: "inset(0% 100% 0% 0%)" },
{ clipPath: "inset(0% 0% 0% 0%)" },
],
{ duration: 550, easing: "ease-out", fill: "both" },
);
}
function selectSeries(key: SeriesKey): void {
if (activeSeriesKey.get() === key) return;
activeSeriesKey.set(key);
optionState.set(buildOption(key));
sweepReveal();
}
function statTile(key: SeriesKey): DomphyElement<"button"> {
const meta = seriesMeta[key];
return {
button: [
// `small()`'s own muted shift-6/7 measured too little contrast
// against this tile's background — shift-9 (an earlier attempt)
// still only measured ~4.24:1 (need 4.5:1); shift-11 clears it with
// real margin while staying visually secondary next to the bold
// total below.
{
small: meta.label,
$: [small({ color: "neutral" })],
style: {
color: (l: Listener) => themeColor(l, "shift-11", "neutral"),
},
} as DomphyElement<"small">,
{
h4: totals[key].toLocaleString("en-US"),
$: [heading({ color: "neutral" })],
} as DomphyElement<"h4">,
],
type: "button",
dataActive: (listener: Listener) =>
activeSeriesKey.get(listener) === key ? "true" : "false",
onClick: () => selectSeries(key),
style: {
display: "flex",
flexDirection: "column",
alignItems: "flex-start",
justifyContent: "center",
gap: themeSpacing(0.5),
flex: "1",
cursor: "pointer",
border: "none",
backgroundColor: "transparent",
paddingBlock: themeSpacing(3),
paddingInline: themeSpacing(4),
textAlign: "left",
"&[data-active=true]": {
// Upstream active tile is `bg-muted/50` (≈ #fafafa): the neutral
// ramp's shift-1 #ededed is the closest step. "increase-1" from the
// card's white surface landed on the muddy shift-2 #dbdbdb.
backgroundColor: (listener: Listener) =>
themeColor(listener, "shift-1", "neutral"),
},
},
} as DomphyElement<"button">;
}
const asideElement: DomphyElement<"aside"> = {
aside: [statTile("desktop"), statTile("mobile")],
style: {
display: "flex",
width: "100%",
// Tile-group leading separator (upstream: buttons carry `border-t` when the
// header stacks, `sm:border-t-0 sm:border-l` when it goes row): a top rule
// above the tiles on narrow viewports, a left rule between the title block
// and the tiles at >=640px.
borderBlockStart: (listener: Listener) =>
`1px solid ${themeColor(listener, "shift-3", "neutral")}`,
color: (listener: Listener) => themeColor(listener, "shift-9", "neutral"),
"@media (min-width: 640px)": {
width: "auto",
borderBlockStart: "none",
borderInlineStart: (listener: Listener) =>
`1px solid ${themeColor(listener, "shift-3", "neutral")}`,
},
"& > button + button": {
borderInlineStart: (listener: Listener) =>
`1px solid ${themeColor(listener, "shift-3", "neutral")}`,
},
},
} as DomphyElement<"aside">;
const plotWrapper: DomphyElement<"div"> = {
div: [
{
div: null,
style: { position: "absolute", inset: "0" },
$: [chart(optionState)],
} as DomphyElement<"div">,
],
style: { position: "relative", width: "100%", height: themeSpacing(72) },
$: [
behavior<RevealFrameProps>("chart-reveal-frame", attachRevealFrame, {
frame: plotFrame,
onAttach: sweepReveal,
}),
],
} as DomphyElement<"div">;
return {
div: [
{ h3: title, $: [heading()] } as DomphyElement<"h3">,
{
p: subtitle,
$: [paragraph({ color: "neutral" })],
} as DomphyElement<"p">,
asideElement,
{
// Full-width rule under the header (upstream CardHeader `border-b`): the
// card grid's "content" area spans both columns, so a top border here
// separates the title/desc/tile header row from the chart below.
div: [plotWrapper],
style: {
borderBlockStart: (listener: Listener) =>
`1px solid ${themeColor(listener, "shift-3", "neutral")}`,
color: (listener: Listener) =>
themeColor(listener, "shift-9", "neutral"),
},
} as DomphyElement<"div">,
],
$: [card({ color: "neutral" })],
style: {
width: "100%",
"@media (max-width: 640px)": {
gridTemplateColumns: "1fr",
gridTemplateAreas: '"image" "title" "aside" "desc" "content" "footer"',
},
},
} as DomphyElement<"div">;
}
export { chartBarInteractive };