Bklit UI
componentbar-chartsource: ok · refreshed
Bar Chart
A composable bar chart with horizontal/vertical orientations and animations
npx shadcn add "https://ui.bklit.com/r/bar-chart.json"free
No selection tags yet — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
@bklit/chart-context@bklit/chart-animation@bklit/grid@bklit/chart-tooltip@bklit/utils
Files13 · source cached 10/8/2026, 5:34:42 AM
src/charts/bar-chart.tsx
"use client";
import { localPoint } from "@visx/event";
import { scaleBand, scaleLinear } from "@visx/scale";
import type { Transition } from "motion/react";
import {
memo,
type ReactElement,
type ReactNode,
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from "react";
import { cn } from "@/lib/utils";
import { DEFAULT_ANIMATION_EASING } from "./animation";
import type { BarProps } from "./bar";
import { topSquareCenterY } from "./bar-squares-layout";
import {
forEachChartChild,
isChartClipPassthrough,
isClipExcludedComponent,
isPostOverlayComponent,
isUnderlayComponent,
renderKeyedChartLayers,
resolveChartChildElement,
} from "./chart-child-passthrough";
import {
ChartProvider,
type LineConfig,
type Margin,
type TooltipData,
} from "./chart-context";
import { isGradientDefComponent, isPatternDefComponent } from "./chart-defs";
import { shortDateFmt } from "./chart-formatters";
import { ParentSize } from "./chart-parent-size";
import {
type ChartPhase,
type ChartStatus,
DEFAULT_CHART_LIFECYCLE,
resolveRestingChartPhase,
} from "./chart-phase";
import { BarLoadingSkeleton } from "./loading-sweep";
import { extractReferenceAreaConfigs } from "./reference-area-config";
import { useScheduledTooltip } from "./use-scheduled-tooltip";
import {
buildYScalesForLines,
getPrimaryYScale,
normalizeYAxisId,
wrapSingleYScale,
} from "./y-axis-scales";
/** Skeleton bars to show when `status="loading"` and `data` is empty. */
const FALLBACK_LOADING_BARS = 12;
export type BarOrientation = "vertical" | "horizontal";
export interface BarChartProps {
/** Data array - each item should have an x-axis key and numeric values */
data: Record<string, unknown>[];
/** Key in data for the categorical axis. Default: "name" */
xDataKey?: string;
/** Chart margins */
margin?: Partial<Margin>;
/** Animation duration in milliseconds. Default: 1100 */
animationDuration?: number;
/** CSS easing for bar grow transitions. */
animationEasing?: string;
/** Motion enter transition (spring or cubic-bezier tween). */
enterTransition?: Transition;
/** Signature of motion URL state — triggers enter replay when it changes. */
revealSignature?: string;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container */
className?: string;
/** Gap between bar groups as a fraction of band width (0-1). Default: 0.2 */
barGap?: number;
/** Fixed bar width in pixels. If not set, bars auto-size to fill the band. */
barWidth?: number;
/** Bar chart orientation. Default: "vertical" */
orientation?: BarOrientation;
/** Whether to stack bars instead of grouping them. Default: false */
stacked?: boolean;
/** Gap between stacked bar segments in pixels. Default: 0 */
stackGap?: number;
/** When set, tooltip Y positions snap to the top square center (shape variant). */
squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean };
/** Child components (Bar, Grid, ChartTooltip, etc.). Optional — omit for a
* pure `status="loading"` skeleton. */
children?: ReactNode;
/** Reports reveal lifecycle for OG screenshots and loading orchestration. */
onPhaseChange?: (phase: ChartPhase) => void;
/** Fetch / display status. When `"loading"`, a shimmer skeleton replaces the
* bars (no chart data required). Default: `"ready"`. */
status?: ChartStatus;
}
const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };
// Extract bar configs from children synchronously
function extractBarConfigs(children: ReactNode): LineConfig[] {
const configs: LineConfig[] = [];
forEachChartChild(children, (child) => {
const childType = child.type as {
displayName?: string;
name?: string;
__isBarDepthLayer?: boolean;
};
// Bar-depth surface layers (BarDepthBack/Front, BarPulse) carry a
// `dataKey` to pair with a Bar but are not series themselves — skip them
// so they don't inflate the series count and shrink the real bars.
if (childType.__isBarDepthLayer) {
return;
}
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
const props = child.props as BarProps | undefined;
const isBarComponent =
componentName === "Bar" ||
componentName === "BarSquares" ||
(props && typeof props.dataKey === "string" && props.dataKey.length > 0);
if (isBarComponent && props?.dataKey) {
// Use stroke for tooltip dot color if provided, otherwise fall back to fill
// This allows gradient/pattern fills to have a solid dot color
const dotColor =
props.stroke || props.fill || "var(--chart-line-primary)";
configs.push({
dataKey: props.dataKey,
stroke: dotColor,
strokeWidth: 0,
yAxisId: props.yAxisId,
});
}
});
return configs;
}
interface ChartInnerProps {
width: number;
height: number;
data: Record<string, unknown>[];
xDataKey: string;
margin: Margin;
animationDuration: number;
animationEasing: string;
enterTransition?: Transition;
revealSignature?: string;
barGap: number;
barWidthProp?: number;
orientation: BarOrientation;
stacked: boolean;
stackGap: number;
squareSnap?: { squareGap: number; groupGap?: number; fit?: boolean };
children: ReactNode;
containerRef: React.RefObject<HTMLDivElement | null>;
onPhaseChange?: (phase: ChartPhase) => void;
status: ChartStatus;
}
function ChartInner(props: ChartInnerProps) {
const { width, height } = props;
if (width < 10 || height < 10) {
return null;
}
return <ChartCore {...props} />;
}
const ChartCore = memo(function ChartCore({
width,
height,
data,
xDataKey,
margin,
animationDuration,
animationEasing,
enterTransition,
revealSignature = "",
barGap,
barWidthProp,
orientation,
stacked,
stackGap,
squareSnap,
children,
containerRef,
onPhaseChange,
status,
}: ChartInnerProps) {
const { tooltipData, setTooltipData, scheduleTooltip, clearTooltip } =
useScheduledTooltip<TooltipData>();
const [isLoaded, setIsLoaded] = useState(false);
const [revealEpoch, setRevealEpoch] = useState(0);
const hoveredBarIndex = tooltipData?.index ?? null;
const isHorizontal = orientation === "horizontal";
// Extract bar configs synchronously from children
const lines = useMemo(() => extractBarConfigs(children), [children]);
const innerWidth = width - margin.left - margin.right;
const innerHeight = height - margin.top - margin.bottom;
// Category accessor function - returns string for categorical scale
const categoryAccessor = useCallback(
(d: Record<string, unknown>): string => {
const value = d[xDataKey];
if (value instanceof Date) {
return shortDateFmt.format(value);
}
return String(value ?? "");
},
[xDataKey]
);
// For compatibility with ChartContext, provide a Date-based xAccessor
const xAccessorDate = useCallback(
(d: Record<string, unknown>): Date => {
const value = d[xDataKey];
if (value instanceof Date) {
return value;
}
return new Date();
},
[xDataKey]
);
// Category scale (band) - for the categorical axis
const categoryScale = useMemo(() => {
const domain = data.map((d) => categoryAccessor(d));
const range: [number, number] = isHorizontal
? [0, innerHeight]
: [0, innerWidth];
return scaleBand<string>({
range,
domain,
padding: barGap,
});
}, [innerWidth, innerHeight, data, categoryAccessor, barGap, isHorizontal]);
// Band width for bars - use prop if provided, otherwise use scale's bandwidth
const bandWidth = barWidthProp ?? categoryScale.bandwidth();
// Compute max value considering stacking
const maxValue = useMemo(() => {
if (stacked) {
// For stacked bars, sum all values at each data point
let max = 0;
for (const d of data) {
let sum = 0;
for (const line of lines) {
const value = d[line.dataKey];
if (typeof value === "number") {
sum += value;
}
}
if (sum > max) {
max = sum;
}
}
return max || 100;
}
// For grouped bars, find max single value
let max = 0;
for (const line of lines) {
for (const d of data) {
const value = d[line.dataKey];
if (typeof value === "number" && value > max) {
max = value;
}
}
}
return max || 100;
}, [data, lines, stacked]);
// Value scale (linear) - for the value axis
const valueScale = useMemo(() => {
const range = isHorizontal ? [0, innerWidth] : [innerHeight, 0];
return scaleLinear({
range,
domain: [0, maxValue * 1.1],
nice: true,
});
}, [innerWidth, innerHeight, maxValue, isHorizontal]);
const yScales = useMemo(() => {
if (isHorizontal) {
return wrapSingleYScale(valueScale);
}
return buildYScalesForLines({
lines,
data,
innerHeight,
resolveDomain: (dataKeys) => {
let max = 0;
for (const d of data) {
for (const key of dataKeys) {
const value = d[key];
if (typeof value === "number" && value > max) {
max = value;
}
}
}
return [0, (max || 100) * 1.1];
},
});
}, [data, innerHeight, isHorizontal, lines, valueScale]);
const primaryYScale = getPrimaryYScale(yScales, valueScale);
// Compute stack offsets for stacked bars
const stackOffsets = useMemo(() => {
if (!stacked) {
return undefined;
}
const offsets = new Map<number, Map<string, number>>();
for (let i = 0; i < data.length; i++) {
const d = data[i];
if (!d) {
continue;
}
const pointOffsets = new Map<string, number>();
let cumulative = 0;
for (const line of lines) {
pointOffsets.set(line.dataKey, cumulative);
const value = d[line.dataKey];
if (typeof value === "number") {
cumulative += value;
}
}
offsets.set(i, pointOffsets);
}
return offsets;
}, [data, lines, stacked]);
// Column width for tooltip indicator
const columnWidth = useMemo(() => {
if (data.length < 1) {
return 0;
}
return isHorizontal ? innerHeight / data.length : innerWidth / data.length;
}, [innerWidth, innerHeight, data.length, isHorizontal]);
// Pre-compute labels for ticker animation
const dateLabels = useMemo(
() => data.map((d) => categoryAccessor(d)),
[data, categoryAccessor]
);
// Create a fake time scale for compatibility with ChartContext
const fakeTimeScale = useMemo(() => {
const now = Date.now();
const start = now - data.length * 24 * 60 * 60 * 1000;
const scale = {
...categoryScale,
domain: () => [new Date(start), new Date(now)],
range: () => [0, innerWidth] as [number, number],
invert: (x: number) => new Date(start + (x / innerWidth) * (now - start)),
copy: () => scale,
};
return scale;
}, [categoryScale, innerWidth, data.length]);
// Animation timing — replay when motion settings change
// biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature
useEffect(() => {
setRevealEpoch((n) => n + 1);
setIsLoaded(false);
// While loading, hold the skeleton (no reveal, no interaction). When
// status flips to "ready" this effect re-runs and plays the grow reveal.
if (status === "loading") {
return;
}
const staggerMs = data.length > 1 ? animationDuration * 0.4 : 0;
const timer = setTimeout(() => {
setIsLoaded(true);
}, animationDuration + staggerMs);
return () => clearTimeout(timer);
}, [animationDuration, revealSignature, status]);
useEffect(() => {
onPhaseChange?.(isLoaded ? "ready" : "revealing");
}, [isLoaded, onPhaseChange]);
// Mouse move handler
const handleMouseMove = useCallback(
(event: React.MouseEvent<SVGGElement>) => {
const point = localPoint(event);
if (!point) {
return;
}
const pos = isHorizontal ? point.y - margin.top : point.x - margin.left;
// Find which band the mouse is over
const bandIndex = Math.floor(pos / columnWidth);
const clampedIndex = Math.max(0, Math.min(data.length - 1, bandIndex));
const d = data[clampedIndex];
if (!d) {
return;
}
// Calculate positions for each bar
const yPositions: Record<string, number> = {};
const xPositions: Record<string, number> = {};
const barPos = categoryScale(categoryAccessor(d)) ?? 0;
if (isHorizontal) {
// Horizontal bars: dots at end of bar (x = value), centered vertically in band
const seriesCount = lines.length;
const groupGap = seriesCount > 1 ? 4 : 0;
const individualBarHeight =
seriesCount > 0
? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
: bandWidth;
if (stacked) {
// Stacked horizontal: all bars same y, x at cumulative end
let cumulative = 0;
for (const line of lines) {
const value = d[line.dataKey];
if (typeof value === "number") {
cumulative += value;
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale;
xPositions[line.dataKey] = axisScale(cumulative) ?? 0;
yPositions[line.dataKey] = barPos + bandWidth / 2;
}
}
} else {
// Grouped horizontal: each bar at its own y position
lines.forEach((line, idx) => {
const value = d[line.dataKey];
if (typeof value === "number") {
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? valueScale;
xPositions[line.dataKey] = axisScale(value) ?? 0;
yPositions[line.dataKey] =
barPos +
idx * (individualBarHeight + groupGap) +
individualBarHeight / 2;
}
});
}
} else if (stacked) {
// Vertical stacked bars
let cumulative = 0;
let seriesIdx = 0;
for (const line of lines) {
const value = d[line.dataKey];
if (typeof value === "number") {
cumulative += value;
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale;
const gapOffset = seriesIdx * stackGap;
yPositions[line.dataKey] = (axisScale(cumulative) ?? 0) - gapOffset;
seriesIdx++;
}
}
} else {
// Vertical grouped bars
const seriesCount = lines.length;
const groupGap = seriesCount > 1 ? 4 : 0;
const individualBarWidth =
seriesCount > 0
? (bandWidth - groupGap * (seriesCount - 1)) / seriesCount
: bandWidth;
lines.forEach((line, idx) => {
const value = d[line.dataKey];
if (typeof value === "number") {
const axisScale =
yScales[normalizeYAxisId(line.yAxisId)] ?? primaryYScale;
const baselineY = axisScale(0) ?? innerHeight;
const valueY = axisScale(value) ?? 0;
const barLengthPx = baselineY - valueY;
if (squareSnap && !isHorizontal && value > 0) {
yPositions[line.dataKey] = topSquareCenterY({
baselineY,
barLengthPx,
squareSize: individualBarWidth,
gap: squareSnap.squareGap,
fit: squareSnap.fit,
});
} else {
yPositions[line.dataKey] = valueY;
}
xPositions[line.dataKey] =
barPos +
idx * (individualBarWidth + groupGap) +
individualBarWidth / 2;
}
});
}
// Tooltip position: for horizontal, position at max bar end; for vertical, center of band
let tooltipX: number;
if (isHorizontal) {
// Position tooltip at the end of the longest bar
const maxX = Math.max(...Object.values(xPositions), 0);
tooltipX = maxX;
} else {
tooltipX = barPos + bandWidth / 2;
}
scheduleTooltip({
point: d,
index: clampedIndex,
x: tooltipX,
yPositions,
xPositions: Object.keys(xPositions).length > 0 ? xPositions : undefined,
});
},
[
categoryScale,
valueScale,
data,
lines,
margin.left,
margin.top,
categoryAccessor,
columnWidth,
bandWidth,
isHorizontal,
stacked,
stackGap,
scheduleTooltip,
yScales,
primaryYScale,
squareSnap,
innerHeight,
]
);
const handleMouseLeave = useCallback(() => {
clearTooltip();
}, [clearTooltip]);
const canInteract = isLoaded;
// Separate children into defs, pre-overlay, and post-overlay
const defsChildren: ReactElement[] = [];
const clipExcludedChildren: ReactElement[] = [];
const underlayChildren: ReactElement[] = [];
const preOverlayChildren: ReactElement[] = [];
const postOverlayChildren: ReactElement[] = [];
forEachChartChild(children, (child) => {
const resolvedChild = resolveChartChildElement(child);
if (isGradientDefComponent(child)) {
defsChildren.push(child);
} else if (isPatternDefComponent(child)) {
preOverlayChildren.push(child);
} else if (isPostOverlayComponent(resolvedChild)) {
postOverlayChildren.push(resolvedChild);
} else if (isClipExcludedComponent(resolvedChild)) {
clipExcludedChildren.push(
isChartClipPassthrough(child.type) ? resolvedChild : child
);
} else if (isUnderlayComponent(resolvedChild)) {
underlayChildren.push(resolvedChild);
} else {
preOverlayChildren.push(child);
}
});
const referenceAreas = useMemo(
() => extractReferenceAreaConfigs(children),
[children]
);
const contextValue = {
...DEFAULT_CHART_LIFECYCLE,
chartPhase: resolveRestingChartPhase(status),
chartStatus: status,
data,
renderData: data,
xScale: fakeTimeScale as unknown as ReturnType<
typeof import("@visx/scale").scaleTime<number>
>,
yScale: isHorizontal ? valueScale : primaryYScale,
yScales,
width,
height,
innerWidth,
innerHeight,
margin,
columnWidth,
tooltipData,
setTooltipData,
containerRef,
lines,
referenceAreas,
isLoaded,
animationDuration,
animationEasing,
enterTransition,
revealEpoch,
xAccessor: xAccessorDate,
dateLabels,
// Bar-specific properties
barScale: categoryScale,
bandWidth,
hoveredBarIndex,
barXAccessor: categoryAccessor,
orientation,
stacked,
stackOffsets,
squareSnap,
};
return (
<ChartProvider value={contextValue}>
<svg
aria-hidden="true"
className="overflow-visible"
height={height}
width={width}
>
{/* Gradient and pattern definitions */}
{defsChildren.length > 0 && <defs>{defsChildren}</defs>}
<rect fill="transparent" height={height} width={width} x={0} y={0} />
{/* biome-ignore lint/a11y/noStaticElementInteractions: Chart interaction area */}
<g
onMouseLeave={canInteract ? handleMouseLeave : undefined}
onMouseMove={canInteract ? handleMouseMove : undefined}
style={{ cursor: canInteract ? "crosshair" : "default" }}
transform={`translate(${margin.left},${margin.top})`}
>
{/* Background rect for mouse event detection */}
<rect
fill="transparent"
height={innerHeight}
width={innerWidth}
x={0}
y={0}
/>
{renderKeyedChartLayers(clipExcludedChildren)}
{renderKeyedChartLayers(underlayChildren)}
{status === "loading" ? (
<BarLoadingSkeleton
barCount={data.length || FALLBACK_LOADING_BARS}
innerHeight={innerHeight}
innerWidth={innerWidth}
/>
) : (
renderKeyedChartLayers(preOverlayChildren)
)}
{/* Markers rendered last so they're on top for interaction */}
{renderKeyedChartLayers(postOverlayChildren)}
</g>
</svg>
</ChartProvider>
);
});
export function BarChart({
data,
xDataKey = "name",
margin: marginProp,
animationDuration = 1100,
animationEasing = DEFAULT_ANIMATION_EASING,
enterTransition,
revealSignature,
aspectRatio = "2 / 1",
className = "",
barGap = 0.2,
barWidth,
orientation = "vertical",
stacked = false,
stackGap = 0,
squareSnap,
children,
onPhaseChange,
status = "ready",
}: BarChartProps) {
const containerRef = useRef<HTMLDivElement>(null);
const margin = { ...DEFAULT_MARGIN, ...marginProp };
return (
<div
className={cn("relative w-full overflow-visible", className)}
ref={containerRef}
style={{ aspectRatio }}
>
<ParentSize debounceTime={10}>
{({ width, height }) => (
<ChartInner
animationDuration={animationDuration}
animationEasing={animationEasing}
barGap={barGap}
barWidthProp={barWidth}
containerRef={containerRef}
data={data}
enterTransition={enterTransition}
height={height}
margin={margin}
onPhaseChange={onPhaseChange}
orientation={orientation}
revealSignature={revealSignature}
squareSnap={squareSnap}
stacked={stacked}
stackGap={stackGap}
status={status}
width={width}
xDataKey={xDataKey}
>
{children}
</ChartInner>
)}
</ParentSize>
</div>
);
}
BarChart.displayName = "BarChart";
export default BarChart;
src/charts/chart-child-passthrough.ts
import {
Children,
cloneElement,
Fragment,
isValidElement,
type ReactElement,
type ReactNode,
} from "react";
/** Marker on wrapper components whose single child should inherit clip classification. */
export const CHART_CLIP_PASSTHROUGH = "__chartClipPassthrough" as const;
export function isChartClipPassthrough(type: unknown): boolean {
return (
typeof type === "function" &&
(type as { [CHART_CLIP_PASSTHROUGH]?: boolean })[CHART_CLIP_PASSTHROUGH] ===
true
);
}
/** Unwrap visibility wrappers so `Grid` / axes stay outside the series clip. */
export function resolveChartChildElement(child: ReactElement): ReactElement {
if (isChartClipPassthrough(child.type)) {
const inner = (child.props as { children?: unknown }).children;
if (isValidElement(inner)) {
return resolveChartChildElement(inner);
}
}
return child;
}
/** Walk chart children, flattening React fragments (studio often groups layers in `<>...</>`). */
export function forEachChartChild(
children: ReactNode,
callback: (child: ReactElement, index: number) => void
) {
let index = 0;
const visit = (nodes: ReactNode) => {
Children.forEach(nodes, (child) => {
if (!isValidElement(child)) {
return;
}
if (child.type === Fragment) {
visit((child.props as { children?: ReactNode }).children);
return;
}
callback(child, index);
index += 1;
});
};
visit(children);
}
const CLIP_EXCLUDED_COMPONENT_NAMES = new Set([
"Background",
"Grid",
"XAxis",
"YAxis",
"BarXAxis",
"BarYAxis",
"LiveXAxis",
"LiveYAxis",
]);
const UNDERLAY_COMPONENT_NAMES = new Set(["ReferenceArea", "BarColumnTrack"]);
/** Markers render after the interaction overlay so they stay clickable. */
export function isPostOverlayComponent(child: ReactElement): boolean {
const childType = child.type as {
displayName?: string;
name?: string;
__isChartMarkers?: boolean;
__isPostOverlay?: boolean;
};
if (childType.__isChartMarkers || childType.__isPostOverlay) {
return true;
}
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return (
componentName === "ChartMarkers" ||
componentName === "MarkerGroup" ||
componentName === "ChartBrush"
);
}
/** Renders above grid/axes but below series; excluded from grow-clip reveal. */
export function isUnderlayComponent(child: ReactElement): boolean {
const childType = child.type as { displayName?: string; name?: string };
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return UNDERLAY_COMPONENT_NAMES.has(componentName);
}
/** Grid and axes stay visible during series clip reveal (e.g. loading → ready). */
export function isClipExcludedComponent(child: ReactElement): boolean {
const childType = child.type as { displayName?: string; name?: string };
const componentName =
typeof child.type === "function"
? childType.displayName || childType.name || ""
: "";
return CLIP_EXCLUDED_COMPONENT_NAMES.has(componentName);
}
/** SVG layer lists from chart shells need stable keys when rendered as arrays. */
export function renderKeyedChartLayers(children: ReactElement[]) {
return children.map((child, index) =>
cloneElement(child, { key: child.key ?? `chart-layer-${index}` })
);
}
src/charts/chart-legend-hover.tsx
"use client";
import { createContext, type ReactNode, useContext, useMemo } from "react";
interface ChartLegendHoverContextValue {
hoveredIndex: number | null;
setHoveredIndex: (index: number | null) => void;
}
const ChartLegendHoverContext =
createContext<ChartLegendHoverContextValue | null>(null);
export function ChartLegendHoverProvider({
hoveredIndex,
onHoverChange,
children,
}: {
hoveredIndex: number | null;
onHoverChange: (index: number | null) => void;
children: ReactNode;
}) {
const value = useMemo(
() => ({ hoveredIndex, setHoveredIndex: onHoverChange }),
[hoveredIndex, onHoverChange]
);
return (
<ChartLegendHoverContext.Provider value={value}>
{children}
</ChartLegendHoverContext.Provider>
);
}
export function useChartLegendHover(): ChartLegendHoverContextValue {
const context = useContext(ChartLegendHoverContext);
return (
context ?? {
hoveredIndex: null,
setHoveredIndex: () => {
/* noop outside ChartLegendHoverProvider */
},
}
);
}
src/charts/bar.tsx
"use client";
import type { scaleBand } from "@visx/scale";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { memo, useId, useMemo } from "react";
import { barDepthAndRise, barDepthMaxDepth } from "./bar-depth-geometry";
import {
chartCssVars,
useChart,
useChartStable,
useYScale,
} from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import { transitionWithDelay } from "./motion-utils";
type ScaleBand<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
export type BarLineCap = "round" | "butt" | number;
export type BarAnimationType = "grow" | "fade";
// ── Bar-depth perspective trim ───────────────────────────────────────────
// Uses the SHARED geometry (`bar-depth-geometry.ts`) so a
// `<Bar perspective>` front face lines up exactly with
// `<BarDepthBack>`'s lid — the formula lives in one place for both.
/** perspectiveRise for a positive bar whose visual top sits at `topY`.
* Returns 0 for a dead-center bar or a dense chart (degenerate depth). */
function barDepthPerspectiveRise(
barScale: ScaleBand<string>,
bandWidth: number,
barXAccessor: (d: Record<string, unknown>) => string,
innerWidth: number,
datum: Record<string, unknown>,
topY: number,
baselineY: number
): number {
const centerX = innerWidth / 2;
if (centerX <= 0) {
return 0;
}
const step =
(barScale as unknown as { step?: () => number }).step?.() ?? bandWidth;
const maxDepth = barDepthMaxDepth(step, bandWidth);
const bandX = barScale(barXAccessor(datum)) ?? 0;
const cx = bandX + bandWidth / 2;
const absOffset = Math.min(1, Math.abs((cx - centerX) / centerX));
const naturalHeight = Math.abs(baselineY - topY);
return barDepthAndRise(absOffset, naturalHeight, maxDepth).perspectiveRise;
}
export interface BarProps {
/** Key in data to use for y values */
dataKey: string;
/** Y-scale group id for vertical bars (Recharts `yAxisId`). Default: `"left"`. */
yAxisId?: string | number;
/** Fill color for the bar. Can be a color, gradient url, or pattern url. Default: var(--chart-line-primary) */
fill?: string;
/** Color for tooltip dot. Use when fill is a gradient/pattern. Default: uses fill value */
stroke?: string;
/** Line cap style for bar ends: "round", "butt", or a number for custom radius. Default: "round" */
lineCap?: BarLineCap;
/** Whether to animate the bars. Default: true */
animate?: boolean;
/** Animation type: "grow" (height) or "fade" (opacity + blur). Default: "grow" */
animationType?: BarAnimationType;
/** Opacity when not hovered (when another bar is hovered). Default: 0.3 */
fadedOpacity?: number;
/** Stagger delay between bars in seconds. Auto-calculated if not provided. */
staggerDelay?: number;
/** Gap between stacked bars in pixels. Default: 0 */
stackGap?: number;
/** Gap between grouped bars in pixels. Default: 4 */
groupGap?: number;
/** Shrink each positive bar's top by its perspective rise so the front face
* lines up with `<BarDepthBack>`'s lid (instead of the lid sitting above the
* front face). Pass `true` whenever the chart also renders the bar-depth 3D
* surfaces. Default: false */
perspective?: boolean;
/** Minimum rendered bar height in px (non-stacked, vertical). Floors short or
* zero-value bars so they stay visible. Pair with the same value on
* `<BarDepthProvider minBarHeight>` when using the 3D surfaces. Default: 0 */
minBarHeight?: number;
}
interface BarInnerProps extends BarProps {
barScale: ScaleBand<string>;
bandWidth: number;
barXAccessor: (d: Record<string, unknown>) => string;
}
interface AnimatedBarProps {
x: number;
y: number;
width: number;
height: number;
fill: string;
rx: number;
ry: number;
index: number;
isFaded: boolean;
animationType: BarAnimationType;
innerHeight: number;
fadedOpacity: number;
staggerDelay: number;
enterTransition?: Transition;
revealEpoch: number;
isHorizontal: boolean;
}
function AnimatedBar({
x,
y,
width,
height,
fill,
rx,
ry,
index,
isFaded,
animationType,
innerHeight,
fadedOpacity,
staggerDelay,
enterTransition,
revealEpoch,
isHorizontal,
}: AnimatedBarProps) {
const enterAnim = transitionWithDelay(enterTransition, index * staggerDelay);
if (animationType === "fade") {
return (
<motion.rect
animate={{
opacity: isFaded ? fadedOpacity : 1,
filter: "blur(0px)",
}}
fill={fill}
height={height}
initial={{ opacity: 0, filter: "blur(2px)" }}
key={`fade-${index}-${revealEpoch}`}
rx={rx}
ry={ry}
transition={enterAnim}
width={width}
x={x}
y={y}
/>
);
}
const initial = isHorizontal
? { width: 0, height, x: 0, y }
: { width, height: 0, x, y: innerHeight };
const target = isHorizontal
? { width, height, x: 0, y }
: { width, height, x, y };
return (
<g
opacity={isFaded ? fadedOpacity : 1}
style={{ transition: "opacity 0.15s ease-in-out" }}
>
<motion.rect
animate={target}
fill={fill}
initial={initial}
key={`grow-${index}-${revealEpoch}`}
rx={rx}
ry={ry}
transition={enterAnim}
/>
</g>
);
}
const BarInner = memo(function BarInner({
dataKey,
yAxisId,
fill = chartCssVars.linePrimary,
lineCap = "round",
animate = true,
animationType = "grow",
fadedOpacity = 0.3,
staggerDelay,
stackGap = 0,
groupGap = 4,
perspective = false,
minBarHeight = 0,
barScale,
bandWidth,
barXAccessor,
}: BarInnerProps) {
const {
data,
yScale: chartYScale,
innerHeight,
innerWidth,
isLoaded,
hoveredBarIndex,
lines,
orientation,
stacked,
stackOffsets,
animationDuration,
enterTransition,
revealEpoch = 0,
} = useChart();
// Calculate stagger delay automatically if not provided
// Total animation duration is ~1200ms, with 40% for stagger spread and 60% for bar animation
const totalAnimDuration = animationDuration || 1100;
const staggerSpread = totalAnimDuration * 0.4; // 40% of time for stagger spread
const calculatedStaggerDelay =
staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);
const uniqueId = useId();
const isHorizontal = orientation === "horizontal";
// Find the index of this bar series among all bar series
const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
const seriesIndex = useMemo(() => {
const idx = lines.findIndex((l) => l.dataKey === dataKey);
return idx >= 0 ? idx : 0;
}, [lines, dataKey]);
const seriesConfig = lines[seriesIndex];
const valueScale = useYScale(yAxisId ?? seriesConfig?.yAxisId);
const isLegendDimmed =
legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex;
const seriesCount = lines.length;
const isLastSeries = seriesIndex === seriesCount - 1;
// Calculate the width for each bar within a group (for non-stacked)
const barWidth = useMemo(() => {
if (!bandWidth || seriesCount === 0) {
return 0;
}
if (stacked) {
// Stacked bars use full band width
return bandWidth;
}
// Leave a gap between grouped bars (controlled by groupGap prop)
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount;
}, [bandWidth, seriesCount, stacked, groupGap]);
// Calculate corner radius based on lineCap. Perspective bars force a flat
// top (radius 0) so the 3D lid from `<BarDepthBack>` meets the bar with no
// gap — rounded corners would leave a wedge, so `perspective` overrides it.
const cornerRadius = useMemo(() => {
if (perspective) {
return 0;
}
if (typeof lineCap === "number") {
return lineCap;
}
if (lineCap === "round" && barWidth) {
return Math.min(barWidth / 2, 8);
}
return 0;
}, [lineCap, barWidth, perspective]);
return (
<g className={`bar-series-${uniqueId}`}>
{data.map((d, i) => {
const value = d[dataKey];
if (typeof value !== "number") {
return null;
}
const categoryValue = barXAccessor(d);
const bandPos = barScale(categoryValue) ?? 0;
let x: number;
let y: number;
let barHeight: number;
let barW: number;
const scale = isHorizontal ? chartYScale : valueScale;
if (isHorizontal) {
// Horizontal bars: category on y-axis, value on x-axis
const valuePos = scale(value) ?? 0;
barW = valuePos; // Width is the value position (grows from left)
barHeight = barWidth;
if (stacked && stackOffsets) {
const offset = stackOffsets.get(i)?.get(dataKey) ?? 0;
x = scale(offset) ?? 0;
barW = valuePos - x;
// Apply stack gap for horizontal: shift right and reduce width
const gapOffset = seriesIndex * stackGap;
x += gapOffset;
if (!isLastSeries && stackGap > 0) {
barW = Math.max(0, barW - stackGap);
}
} else {
x = 0;
// For grouped bars, offset y position
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
y = bandPos + seriesIndex * (barWidth + effectiveGroupGap);
}
y = stacked
? bandPos
: bandPos +
seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0));
} else {
// Vertical bars: category on x-axis, value on y-axis
const valuePos = scale(value) ?? 0;
barHeight = innerHeight - valuePos;
barW = barWidth;
if (stacked && stackOffsets) {
const offset = stackOffsets.get(i)?.get(dataKey) ?? 0;
const offsetY = scale(offset) ?? innerHeight;
// Apply stack gap: shift up and reduce height
const gapOffset = seriesIndex * stackGap;
y = offsetY - barHeight - gapOffset;
// Reduce height slightly for non-last bars to create visual gap
if (!isLastSeries && stackGap > 0) {
barHeight = Math.max(0, barHeight - stackGap);
}
} else {
y = valuePos;
// For grouped bars, offset x position
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
x = bandPos + seriesIndex * (barWidth + effectiveGroupGap);
}
x = stacked
? bandPos
: bandPos +
seriesIndex * (barWidth + (seriesCount > 1 ? groupGap : 0));
// Minimum visible height — floor short/zero non-stacked bars so a
// zero-value data point still reads as a tiny bar instead of
// vanishing. Grows up from the baseline. Floored bars skip the
// perspective trim (sub-pixel on a 3px bar; keeps the front aligned
// with bar-depth, which also skips trim for floored bars).
let isFloored = false;
if (
!stacked &&
minBarHeight > 0 &&
value >= 0 &&
barHeight < minBarHeight
) {
const baselineY = scale(0) ?? innerHeight;
barHeight = minBarHeight;
y = baselineY - minBarHeight;
isFloored = true;
}
// Perspective trim — shrink the topmost positive bar's front-face
// top down by its perspective rise so it meets `<BarDepthBack>`'s
// lid back edge. Stacked: only the last (topmost) series; grouped or
// single: every positive bar. Clamped to `barHeight - 1` so very
// short bars keep a positive height (matches bar-depth's clamp).
if (
perspective &&
value > 0 &&
!isFloored &&
(!stacked || isLastSeries)
) {
const baselineY = scale(0) ?? innerHeight;
const rise = barDepthPerspectiveRise(
barScale,
bandWidth,
barXAccessor,
innerWidth,
d,
y,
baselineY
);
const trim = Math.min(rise, Math.max(0, barHeight - 1));
y += trim;
barHeight -= trim;
}
}
const isFaded =
(hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed;
// Use categoryValue as key since it's the unique identifier from data
const barKey = `bar-${dataKey}-${categoryValue}`;
// Apply rounded corners:
// - For non-stacked: always apply
// - For stacked with gap: apply to all bars
// - For stacked without gap: only apply to the last series
const applyRounding = !stacked || stackGap > 0 || isLastSeries;
const effectiveRx = applyRounding ? cornerRadius : 0;
const effectiveRy = applyRounding ? cornerRadius : 0;
if (animate && !isLoaded) {
return (
<AnimatedBar
animationType={animationType}
enterTransition={enterTransition}
fadedOpacity={fadedOpacity}
fill={fill}
height={barHeight}
index={i}
innerHeight={innerHeight}
isFaded={isFaded}
isHorizontal={isHorizontal}
key={barKey}
revealEpoch={revealEpoch}
rx={effectiveRx}
ry={effectiveRy}
staggerDelay={calculatedStaggerDelay}
width={barW}
x={x}
y={y}
/>
);
}
// Static bar after animation completes
return (
<rect
fill={fill}
height={barHeight}
key={barKey}
opacity={isFaded ? fadedOpacity : 1}
rx={effectiveRx}
ry={effectiveRy}
style={{
cursor: "default",
transition: "opacity 0.15s ease-in-out",
}}
width={barW}
x={x}
y={y}
/>
);
})}
</g>
);
});
export function Bar(props: BarProps) {
const { barScale, bandWidth, barXAccessor } = useChartStable();
if (!(barScale && bandWidth && barXAccessor)) {
console.warn("Bar component must be used within a BarChart");
return null;
}
return (
<BarInner
{...props}
bandWidth={bandWidth}
barScale={barScale}
barXAccessor={barXAccessor}
/>
);
}
Bar.displayName = "Bar";
export default Bar;
src/charts/bar-squares.tsx
"use client";
import type { scaleBand } from "@visx/scale";
import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { memo, useId, useMemo } from "react";
import { computeSquareColumn } from "./bar-squares-layout";
import {
chartCssVars,
useChart,
useChartStable,
useYScale,
} from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import { transitionWithDelay } from "./motion-utils";
import { type PatternPresetId, renderPatternPreset } from "./pattern-preset";
type ScaleBand<Domain extends { toString(): string }> = ReturnType<
typeof scaleBand<Domain>
>;
export interface GradientStop {
offset: number;
color: string;
}
export interface BarSquaresProps {
dataKey: string;
yAxisId?: string | number;
/** Fill color, gradient url, or pattern url. Default: var(--chart-line-primary) */
fill?: string;
/** Tooltip dot / ring stroke color when fill is gradient/pattern */
stroke?: string;
/** Gap between stacked squares in pixels. Default: 3 */
squareGap?: number;
/** Corner radius as a fraction of square size (0 = flat, 0.5 = circle). Default: 0.25 */
squareRadius?: number;
/** Redistribute gap so columns fit bar height exactly */
squareFit?: boolean;
/** Apply bar-spanning gradient from gradientStops */
useGradient?: boolean;
gradientStops?: GradientStop[];
/** Pattern preset when fill is a pattern (for gradient tinting) */
patternPreset?: PatternPresetId;
animate?: boolean;
fadedOpacity?: number;
staggerDelay?: number;
groupGap?: number;
}
export interface BarColumnTrackProps {
/** Fill color or pattern url. Default: var(--chart-grid) */
fill?: string;
opacity?: number;
squareGap?: number;
/** Corner radius fraction (matches squares). Default: 0.25 */
squareRadius?: number;
groupGap?: number;
squareFit?: boolean;
staggerDelay?: number;
}
interface BarSquaresInnerProps extends BarSquaresProps {
barScale: ScaleBand<string>;
bandWidth: number;
barXAccessor: (d: Record<string, unknown>) => string;
}
interface SquareColumnProps {
x: number;
baselineY: number;
barLengthPx: number;
squareSize: number;
squareGap: number;
squareRadius: number;
squareFit: boolean;
fill: string;
useGradient: boolean;
gradientStops: GradientStop[];
patternPreset?: PatternPresetId;
index: number;
isFaded: boolean;
fadedOpacity: number;
animate: boolean;
staggerDelay: number;
animationDuration: number;
enterTransition?: Transition;
revealEpoch: number;
}
function isPatternFill(fill: string): boolean {
return fill.startsWith("url(");
}
/** Delay between stacked squares within one column (bottom → top). */
function squareCascadeStepSeconds(
enterTransition: Transition | undefined,
animationDurationMs: number,
squareCount: number
): number {
if (squareCount <= 1) {
return 0;
}
const durationMs =
enterTransition?.type === "tween" &&
typeof enterTransition.duration === "number"
? enterTransition.duration * 1000
: animationDurationMs;
const cascadeSpreadMs = durationMs * 0.4;
return cascadeSpreadMs / 1000 / (squareCount - 1);
}
function cascadeColumnTransition(
enterTransition: Transition | undefined,
animationDurationMs: number,
columnIndex: number,
columnStaggerDelay: number,
squareCount: number
): Transition {
const cascadeStep = squareCascadeStepSeconds(
enterTransition,
animationDurationMs,
squareCount
);
const base = transitionWithDelay(
enterTransition,
columnIndex * columnStaggerDelay
);
if (squareCount <= 1 || base.type !== "tween") {
return base;
}
const baseDuration =
typeof base.duration === "number"
? base.duration
: animationDurationMs / 1000;
return {
...base,
duration: baseDuration + cascadeStep * (squareCount - 1),
};
}
function SquareColumn({
x,
baselineY,
barLengthPx,
squareSize,
squareGap,
squareRadius,
squareFit,
fill,
useGradient,
gradientStops,
patternPreset,
index,
isFaded,
fadedOpacity,
animate,
staggerDelay,
animationDuration,
enterTransition,
revealEpoch,
}: SquareColumnProps) {
const layout = useMemo(
() =>
computeSquareColumn({
barLengthPx,
squareSize,
gap: squareGap,
fit: squareFit,
}),
[barLengthPx, squareSize, squareGap, squareFit]
);
const rx = squareSize * squareRadius;
const columnTop = baselineY - layout.columnHeight;
const gradientId = `bar-squares-gradient-${index}-${revealEpoch}`;
const patternFill = isPatternFill(fill);
const patternId = `bar-squares-pattern-${index}-${revealEpoch}`;
const effectiveFill = useMemo(() => {
if (useGradient) {
if (patternFill && patternPreset && patternPreset !== "none") {
return `url(#${patternId})`;
}
return `url(#${gradientId})`;
}
return fill;
}, [useGradient, patternFill, patternPreset, fill, gradientId, patternId]);
const cascadeStep = squareCascadeStepSeconds(
enterTransition,
animationDuration,
layout.count
);
const squareOpacity = isFaded ? fadedOpacity : 1;
const gradientPatternNode =
useGradient && patternFill && patternPreset && patternPreset !== "none"
? renderPatternPreset(patternPreset, patternId, {
color: `url(#${gradientId})`,
})
: null;
const gradientDefs = useGradient ? (
<defs>
<linearGradient
gradientUnits="userSpaceOnUse"
id={gradientId}
x1={0}
x2={0}
y1={baselineY}
y2={columnTop}
>
{gradientStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.color}`}
offset={`${stop.offset}%`}
stopColor={stop.color}
/>
))}
</linearGradient>
{gradientPatternNode}
</defs>
) : null;
const squares = layout.positions.map((relY, squareIndex) => {
const y = columnTop + relY;
const bottomY = y + squareSize;
const key = `sq-${index}-${squareIndex}-${revealEpoch}`;
if (!animate) {
return (
<rect
fill={effectiveFill}
height={squareSize}
key={key}
opacity={squareOpacity}
rx={rx}
ry={rx}
width={squareSize}
x={x}
y={y}
/>
);
}
return (
<motion.rect
animate={{ attrY: y, height: squareSize, opacity: squareOpacity }}
fill={effectiveFill}
height={squareSize}
initial={{ attrY: bottomY, height: 0, opacity: 1 }}
key={key}
rx={rx}
ry={rx}
transition={{
...transitionWithDelay(
enterTransition,
index * staggerDelay + squareIndex * cascadeStep
),
opacity: { duration: 0.15 },
}}
width={squareSize}
x={x}
/>
);
});
return (
<>
{gradientDefs}
{squares}
</>
);
}
const BarSquaresInner = memo(function BarSquaresInner({
dataKey,
yAxisId,
fill = chartCssVars.linePrimary,
squareGap = 3,
squareRadius = 0.25,
squareFit = false,
useGradient = false,
gradientStops = [],
patternPreset,
animate = true,
fadedOpacity = 0.3,
staggerDelay,
groupGap = 4,
barScale,
bandWidth,
barXAccessor,
}: BarSquaresInnerProps) {
const {
data,
innerHeight,
hoveredBarIndex,
lines,
orientation,
stacked,
animationDuration,
enterTransition,
revealEpoch = 0,
} = useChart();
const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();
const uniqueId = useId();
const isHorizontal = orientation === "horizontal";
const isUnsupported = isHorizontal || stacked;
const seriesIndex = useMemo(() => {
const idx = lines.findIndex((l) => l.dataKey === dataKey);
return idx >= 0 ? idx : 0;
}, [lines, dataKey]);
const seriesConfig = lines[seriesIndex];
const valueScale = useYScale(yAxisId ?? seriesConfig?.yAxisId);
const isLegendDimmed =
legendHoveredIndex !== null && legendHoveredIndex !== seriesIndex;
const seriesCount = lines.length;
const squareSize = useMemo(() => {
if (!bandWidth || seriesCount === 0) {
return 0;
}
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount;
}, [bandWidth, seriesCount, groupGap]);
const totalAnimDuration = animationDuration || 1100;
const staggerSpread = totalAnimDuration * 0.4;
const calculatedStaggerDelay =
staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);
const baselineY = valueScale(0) ?? innerHeight;
const stops =
gradientStops.length >= 2
? gradientStops
: [
{ offset: 0, color: fill },
{ offset: 100, color: fill },
];
if (isUnsupported) {
return null;
}
return (
<g className={`bar-squares-${uniqueId}`}>
{data.map((d, i) => {
const value = d[dataKey];
if (typeof value !== "number" || value <= 0) {
return null;
}
const categoryValue = barXAccessor(d);
const bandPos = barScale(categoryValue) ?? 0;
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
const x = bandPos + seriesIndex * (squareSize + effectiveGroupGap);
const valuePos = valueScale(value) ?? 0;
const barLengthPx = baselineY - valuePos;
const isFaded =
(hoveredBarIndex !== null && hoveredBarIndex !== i) || isLegendDimmed;
return (
<SquareColumn
animate={animate}
animationDuration={animationDuration || 1100}
barLengthPx={barLengthPx}
baselineY={baselineY}
enterTransition={enterTransition}
fadedOpacity={fadedOpacity}
fill={fill}
gradientStops={stops}
index={i}
isFaded={isFaded}
key={`bar-squares-${dataKey}-${categoryValue}`}
patternPreset={patternPreset}
revealEpoch={revealEpoch}
squareFit={squareFit}
squareGap={squareGap}
squareRadius={squareRadius}
squareSize={squareSize}
staggerDelay={calculatedStaggerDelay}
useGradient={useGradient}
x={x}
/>
);
})}
</g>
);
});
export function BarSquares(props: BarSquaresProps) {
const { barScale, bandWidth, barXAccessor } = useChartStable();
if (!(barScale && bandWidth && barXAccessor)) {
console.warn("BarSquares must be used within a BarChart");
return null;
}
return (
<BarSquaresInner
{...props}
bandWidth={bandWidth}
barScale={barScale}
barXAccessor={barXAccessor}
/>
);
}
BarSquares.displayName = "BarSquares";
const BarColumnTrackInner = memo(function BarColumnTrackInner({
fill = chartCssVars.grid,
opacity = 0.3,
squareGap = 3,
squareRadius = 0.25,
squareFit = false,
groupGap = 4,
staggerDelay,
barScale,
bandWidth,
barXAccessor,
}: BarColumnTrackProps & {
barScale: ScaleBand<string>;
bandWidth: number;
barXAccessor: (d: Record<string, unknown>) => string;
}) {
const {
data,
lines,
orientation,
stacked,
hoveredBarIndex,
animationDuration,
enterTransition,
revealEpoch = 0,
} = useChart();
const uniqueId = useId();
const isHorizontal = orientation === "horizontal";
const isUnsupported = isHorizontal || stacked;
const seriesCount = lines.length;
const squareSize = useMemo(() => {
if (!bandWidth || seriesCount === 0) {
return 0;
}
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
return (bandWidth - effectiveGroupGap * (seriesCount - 1)) / seriesCount;
}, [bandWidth, seriesCount, groupGap]);
const totalAnimDuration = animationDuration || 1100;
const staggerSpread = totalAnimDuration * 0.4;
const calculatedStaggerDelay =
staggerDelay ?? (data.length > 1 ? staggerSpread / 1000 / data.length : 0);
if (isUnsupported) {
return null;
}
const rx = squareSize * squareRadius;
const effectiveOpacity = hoveredBarIndex === null ? opacity : 0;
return (
<g
className={`bar-column-track-${uniqueId}`}
style={{ transition: "opacity 0.15s ease-in-out" }}
>
{data.map((d, i) => {
const categoryValue = barXAccessor(d);
const bandPos = barScale(categoryValue) ?? 0;
const effectiveGroupGap = seriesCount > 1 ? groupGap : 0;
return lines.map((line, seriesIndex) => (
<TrackColumn
animate
bandPos={bandPos}
d={d}
dataKey={line.dataKey}
effectiveGroupGap={effectiveGroupGap}
effectiveOpacity={effectiveOpacity}
enterTransition={enterTransition}
fill={fill}
index={i}
key={`track-${i}-${line.dataKey}`}
revealEpoch={revealEpoch}
rx={rx}
seriesIndex={seriesIndex}
squareFit={squareFit}
squareGap={squareGap}
squareSize={squareSize}
staggerDelay={calculatedStaggerDelay}
yAxisId={line.yAxisId}
/>
));
})}
</g>
);
});
function TrackColumn({
d,
dataKey,
yAxisId,
bandPos,
seriesIndex,
effectiveGroupGap,
squareSize,
squareGap,
squareFit,
fill,
rx,
effectiveOpacity,
index,
staggerDelay,
animate,
enterTransition,
revealEpoch,
}: {
d: Record<string, unknown>;
dataKey: string;
yAxisId?: string | number;
bandPos: number;
seriesIndex: number;
effectiveGroupGap: number;
squareSize: number;
squareGap: number;
squareFit: boolean;
fill: string;
rx: number;
effectiveOpacity: number;
index: number;
staggerDelay: number;
animate: boolean;
enterTransition?: Transition;
revealEpoch: number;
}) {
const { innerHeight, animationDuration: chartAnimationDuration } = useChart();
const valueScale = useYScale(yAxisId);
const value = d[dataKey];
if (typeof value !== "number" || value <= 0) {
return null;
}
const baselineY = valueScale(0) ?? innerHeight;
const valuePos = valueScale(value) ?? 0;
const barLengthPx = baselineY - valuePos;
const layout = computeSquareColumn({
barLengthPx,
squareSize,
gap: squareGap,
fit: squareFit,
});
const columnTop = baselineY - layout.columnHeight;
const trackHeight = Math.max(0, columnTop);
if (trackHeight <= 0 && !animate) {
return null;
}
const x = bandPos + seriesIndex * (squareSize + effectiveGroupGap);
const enterAnim = cascadeColumnTransition(
enterTransition,
chartAnimationDuration || 1100,
index,
staggerDelay,
layout.count
);
const animatedHeight = trackHeight > 0 ? trackHeight : 0;
if (animate) {
return (
<motion.rect
animate={{ height: animatedHeight, y: 0 }}
fill={fill}
height={animatedHeight}
initial={{ height: baselineY, y: 0 }}
key={`track-${index}-${seriesIndex}-${revealEpoch}`}
opacity={effectiveOpacity}
rx={rx}
ry={rx}
transition={enterAnim}
width={squareSize}
x={x}
/>
);
}
if (trackHeight <= 0) {
return null;
}
return (
<rect
fill={fill}
height={trackHeight}
opacity={effectiveOpacity}
rx={rx}
ry={rx}
width={squareSize}
x={x}
y={0}
/>
);
}
export function BarColumnTrack(props: BarColumnTrackProps) {
const { barScale, bandWidth, barXAccessor } = useChartStable();
if (!(barScale && bandWidth && barXAccessor)) {
console.warn("BarColumnTrack must be used within a BarChart");
return null;
}
return (
<BarColumnTrackInner
{...props}
bandWidth={bandWidth}
barScale={barScale}
barXAccessor={barXAccessor}
/>
);
}
BarColumnTrack.displayName = "BarColumnTrack";
export default BarSquares;
src/charts/bar-squares-layout.ts
export interface SquareColumnLayout {
/** Number of squares in the column */
count: number;
/** Top-left Y of each square, bottom square first (relative to bar top at 0) */
positions: number[];
/** Quantized column height in pixels */
columnHeight: number;
squareSize: number;
/** Effective gap between squares (may differ when fit mode redistributes) */
gap: number;
}
export interface SquareColumnInput {
/** Raw bar length in pixels (baseline − value) */
barLengthPx: number;
/** Square width/height — typically equals bar width */
squareSize: number;
/** Gap between stacked squares in pixels */
gap: number;
/** When true, redistribute gap so column height matches barLengthPx exactly */
fit?: boolean;
}
/** Quantize bar length into a stack of square cells. */
export function computeSquareColumn({
barLengthPx,
squareSize,
gap,
fit = false,
}: SquareColumnInput): SquareColumnLayout {
if (barLengthPx <= 0 || squareSize <= 0) {
return { count: 0, positions: [], columnHeight: 0, squareSize, gap };
}
if (fit) {
const count = Math.max(
1,
Math.floor((barLengthPx + gap) / (squareSize + gap))
);
const effectiveGap =
count > 1
? Math.max(0, (barLengthPx - count * squareSize) / (count - 1))
: 0;
const step = squareSize + effectiveGap;
const columnHeight = barLengthPx;
const positions: number[] = [];
for (let i = 0; i < count; i++) {
positions.push(columnHeight - squareSize - i * step);
}
return {
count,
positions,
columnHeight,
squareSize,
gap: effectiveGap,
};
}
const step = squareSize + gap;
const count = Math.max(1, Math.round(barLengthPx / step));
const columnHeight = count * squareSize + Math.max(0, count - 1) * gap;
const positions: number[] = [];
for (let i = 0; i < count; i++) {
const offsetFromBottom = i * step;
positions.push(columnHeight - squareSize - offsetFromBottom);
}
return { count, positions, columnHeight, squareSize, gap };
}
/** Y center of the topmost square in a vertical column. */
export function topSquareCenterY({
baselineY,
barLengthPx,
squareSize,
gap,
fit = false,
}: SquareColumnInput & { baselineY: number }): number {
const {
count,
squareSize: size,
columnHeight,
} = computeSquareColumn({
barLengthPx,
squareSize,
gap,
fit,
});
if (count === 0) {
return baselineY;
}
const topY = baselineY - columnHeight;
return topY + size / 2;
}
src/charts/pattern-preset.tsx
"use client";
import type { ReactNode } from "react";
import { PatternCircles, PatternLines } from "./visx-pattern";
export const PATTERN_PRESET_IDS = [
"none",
"diagonal",
"horizontal",
"vertical",
"cross",
"dots",
"circles",
"accent",
] as const;
export type PatternPresetId = (typeof PATTERN_PRESET_IDS)[number];
export interface PatternPresetOptions {
color?: string;
scale?: number;
strokeWidth?: number;
radius?: number;
complement?: boolean;
fill?: string;
/** Dot grid only — when false, render hollow dots (stroke only). Default: true */
dotFill?: boolean;
tileBackground?: string;
}
/** Presets rendered with @visx/pattern `PatternCircles`. */
export function isCirclePattern(preset: PatternPresetId): boolean {
return preset === "circles" || preset === "dots";
}
/** @deprecated Use `isCirclePattern`. */
export function isCirclesPattern(preset: PatternPresetId): boolean {
return isCirclePattern(preset);
}
export function patternPresetTileSize(
preset: PatternPresetId,
scale = 1
): { width: number; height: number; strokeWidth: number } {
let base = { width: 6, height: 6, strokeWidth: 1 };
if (preset === "dots") {
base = { width: 10, height: 10, strokeWidth: 0 };
} else if (preset === "cross") {
base = { width: 8, height: 8, strokeWidth: 1 };
} else if (preset === "circles") {
base = { width: 6, height: 6, strokeWidth: 1 };
}
return {
width: base.width * scale,
height: base.height * scale,
strokeWidth: base.strokeWidth * scale,
};
}
function renderPatternCircles(
preset: "dots" | "circles",
_id: string,
color: string,
common: {
id: string;
height: number;
width: number;
strokeWidth: number;
background?: string;
},
options: PatternPresetOptions,
scale: number
) {
const isDotGrid = preset === "dots";
const radius =
options.radius ?? (isDotGrid ? Math.max(0.5, 1.5 * scale) : 2 * scale);
const dotFillEnabled = options.dotFill !== false;
if (isDotGrid) {
const dotFill = dotFillEnabled ? options.fill || color : undefined;
return (
<PatternCircles
{...common}
complement={options.complement}
fill={dotFill}
radius={radius}
stroke={dotFillEnabled && options.fill ? undefined : color}
strokeWidth={
dotFillEnabled && !options.fill
? (options.strokeWidth ?? 0)
: (options.strokeWidth ?? 1)
}
/>
);
}
return (
<PatternCircles
{...common}
complement={options.complement}
fill={options.fill || undefined}
radius={radius}
stroke={color}
strokeWidth={options.strokeWidth ?? common.strokeWidth}
/>
);
}
/** Renders a @visx/pattern definition node for the given preset. */
export function renderPatternPreset(
preset: PatternPresetId,
id: string,
options: PatternPresetOptions = {}
): ReactNode {
if (preset === "none") {
return null;
}
const color = options.color ?? "var(--chart-1)";
const scale = options.scale ?? 1;
const tile = patternPresetTileSize(preset, scale);
const common = {
id,
height: tile.height,
width: tile.width,
strokeWidth: tile.strokeWidth,
...(options.tileBackground ? { background: options.tileBackground } : {}),
};
if (preset === "dots" || preset === "circles") {
return renderPatternCircles(preset, id, color, common, options, scale);
}
const strokeWidth = options.strokeWidth ?? tile.strokeWidth;
switch (preset) {
case "diagonal":
return (
<PatternLines
{...common}
orientation={["diagonal"]}
stroke={color}
strokeWidth={strokeWidth}
/>
);
case "horizontal":
return (
<PatternLines
{...common}
orientation={["horizontal"]}
stroke={color}
strokeWidth={strokeWidth}
/>
);
case "vertical":
return (
<PatternLines
{...common}
orientation={["vertical"]}
stroke={color}
strokeWidth={strokeWidth}
/>
);
case "cross":
return (
<PatternLines
{...common}
orientation={["diagonal", "diagonalRightToLeft"]}
stroke={color}
strokeWidth={strokeWidth}
/>
);
case "accent":
return (
<PatternLines
{...common}
orientation={["diagonal"]}
stroke="#e879f9"
strokeWidth={strokeWidth}
/>
);
default:
return null;
}
}
src/charts/visx-pattern.tsx
"use client";
import type { ComponentProps } from "react";
import {
PatternCircles as VisxPatternCircles,
PatternHexagons as VisxPatternHexagons,
PatternLines as VisxPatternLines,
PatternWaves as VisxPatternWaves,
} from "./patterns/index";
export function PatternLines(props: ComponentProps<typeof VisxPatternLines>) {
return <VisxPatternLines {...props} />;
}
PatternLines.displayName = "PatternLines";
export function PatternCircles(
props: ComponentProps<typeof VisxPatternCircles>
) {
return <VisxPatternCircles {...props} />;
}
PatternCircles.displayName = "PatternCircles";
export function PatternWaves(props: ComponentProps<typeof VisxPatternWaves>) {
return <VisxPatternWaves {...props} />;
}
PatternWaves.displayName = "PatternWaves";
export function PatternHexagons(
props: ComponentProps<typeof VisxPatternHexagons>
) {
return <VisxPatternHexagons {...props} />;
}
PatternHexagons.displayName = "PatternHexagons";
src/charts/bar-depth-geometry.ts
/**
* Shared 3D bar-depth geometry — the single source of truth for the
* perspective math used by BOTH `<Bar perspective>` (which shrinks a
* bar's front-face top) AND the bar-depth surfaces (`<BarDepthBack>`'s side +
* lid). Keeping the formula here means the front face and the 3D lid can never
* drift out of alignment.
*/
/** Hard ceiling on side-face thickness in px. Capped further by the column gap
* so depth never bleeds past the next bar's leading edge. */
export const BAR_DEPTH_MAX_PX = 7;
/** The side parallelogram's back edge lifts by `depth * this ratio`, giving a
* subtle head-on perspective slope. */
export const BAR_DEPTH_PERSPECTIVE_RATIO = 0.45;
/**
* Maximum side-face depth in px for a chart, clamped so depth never spills past
* the gap between bars. `stepWidth` is d3-scaleBand's `step()` (bandwidth +
* gap); `bandWidth` is a single bar's width. Returns 0 for gapless/dense charts.
*/
export function barDepthMaxDepth(stepWidth: number, bandWidth: number): number {
const gap = Math.max(0, stepWidth - bandWidth);
return Math.min(bandWidth * 0.22, Math.max(0, gap - 1), BAR_DEPTH_MAX_PX);
}
/**
* Per-bar side-face depth + perspective rise.
*
* - `absOffset` ∈ [0, 1]: the bar's normalized distance from the chart's
* horizontal center. 0 = dead center (no depth); 1 = chart edge (full depth).
* - `naturalHeight`: the bar's pixel height. Depth is capped by it so a short
* bar's side never reads wider than the bar is tall.
* - `maxDepth`: from `barDepthMaxDepth`.
*/
export function barDepthAndRise(
absOffset: number,
naturalHeight: number,
maxDepth: number
): { depth: number; perspectiveRise: number } {
const offset = Math.min(1, Math.max(0, absOffset));
const cappedMaxDepth = Math.min(maxDepth, Math.max(0, naturalHeight));
const depth = offset * cappedMaxDepth;
return { depth, perspectiveRise: depth * BAR_DEPTH_PERSPECTIVE_RATIO };
}
src/charts/bar-x-axis.tsx
"use client";
import { motion } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";
export interface BarXAxisProps {
/** Width of the date ticker box for fade calculation. Default: 50 */
tickerHalfWidth?: number;
/** Whether to show all labels or skip some for dense data. Default: false */
showAllLabels?: boolean;
/** Maximum number of labels to show. Default: 12 */
maxLabels?: number;
}
interface BarXAxisLabelProps {
label: string;
x: number;
crosshairX: number | null;
isHovering: boolean;
tickerHalfWidth: number;
}
function BarXAxisLabel({
label,
x,
crosshairX,
isHovering,
tickerHalfWidth,
}: BarXAxisLabelProps) {
const fadeBuffer = 20;
const fadeRadius = tickerHalfWidth + fadeBuffer;
let opacity = 1;
if (isHovering && crosshairX !== null) {
const distance = Math.abs(x - crosshairX);
if (distance < tickerHalfWidth) {
opacity = 0;
} else if (distance < fadeRadius) {
opacity = (distance - tickerHalfWidth) / fadeBuffer;
}
}
// Zero-width container approach for perfect centering
return (
<div
className="absolute"
style={{
left: x,
bottom: 12,
width: 0,
display: "flex",
justifyContent: "center",
}}
>
<motion.span
animate={{ opacity }}
className={cn("whitespace-nowrap text-chart-label text-xs")}
initial={{ opacity: 1 }}
transition={{ duration: 0.4, ease: "easeInOut" }}
>
{label}
</motion.span>
</div>
);
}
export function BarXAxis(props: BarXAxisProps) {
const { containerRef, barScale } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
if (!barScale) {
return null;
}
return <BarXAxisInner {...props} container={container} />;
}
const BarXAxisInner = memo(function BarXAxisInner({
tickerHalfWidth = 50,
showAllLabels = false,
maxLabels = 12,
container,
}: BarXAxisProps & { container: HTMLDivElement }) {
const { margin, tooltipData, barScale, bandWidth, barXAccessor, data } =
useChart();
// Generate labels for each bar
const labelsToShow = useMemo(() => {
if (!(barScale && bandWidth && barXAccessor)) {
return [];
}
const allLabels = data.map((d) => {
const label = barXAccessor(d);
const bandX = barScale(label) ?? 0;
// Center the label under the bar group
const x = bandX + bandWidth / 2 + margin.left;
return { label, x };
});
// If showAllLabels is true or we have fewer than maxLabels, show all
if (showAllLabels || allLabels.length <= maxLabels) {
return allLabels;
}
// Otherwise, skip some labels to avoid crowding
const step = Math.ceil(allLabels.length / maxLabels);
return allLabels.filter((_, i) => i % step === 0);
}, [
barScale,
bandWidth,
barXAccessor,
data,
margin.left,
showAllLabels,
maxLabels,
]);
const isHovering = tooltipData !== null;
const crosshairX = tooltipData ? tooltipData.x + margin.left : null;
return createPortal(
<div className="pointer-events-none absolute inset-0">
{labelsToShow.map((item) => (
<BarXAxisLabel
crosshairX={crosshairX}
isHovering={isHovering}
key={`${item.label}-${item.x}`}
label={item.label}
tickerHalfWidth={tickerHalfWidth}
x={item.x}
/>
))}
</div>,
container
);
});
BarXAxis.displayName = "BarXAxis";
export default BarXAxis;
src/charts/bar-y-axis.tsx
"use client";
import { motion } from "motion/react";
import { memo, useEffect, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { cn } from "@/lib/utils";
import { useChart, useChartStable } from "./chart-context";
export interface BarYAxisProps {
/** Whether to show all labels or skip some for dense data. Default: true */
showAllLabels?: boolean;
/** Maximum number of labels to show. Default: 20 */
maxLabels?: number;
}
interface BarYAxisLabelProps {
label: string;
y: number;
bandHeight: number;
isHovered: boolean;
}
function BarYAxisLabel({
label,
y,
bandHeight,
isHovered,
}: BarYAxisLabelProps) {
return (
<div
className="absolute right-0 flex items-center justify-end pr-2"
style={{
top: y,
height: bandHeight,
}}
>
<motion.span
animate={{
opacity: isHovered ? 1 : 0.7,
color: isHovered
? "var(--foreground)"
: "var(--chart-label, var(--color-zinc-500))",
}}
className={cn("truncate whitespace-nowrap text-right text-xs")}
initial={{
opacity: 0.7,
color: "var(--chart-label, var(--color-zinc-500))",
}}
style={{ maxWidth: 70 }}
transition={{ duration: 0.15 }}
>
{label}
</motion.span>
</div>
);
}
export function BarYAxis(props: BarYAxisProps) {
const { containerRef, barScale } = useChartStable();
const [mounted, setMounted] = useState(false);
useEffect(() => {
setMounted(true);
}, []);
const container = containerRef.current;
if (!(mounted && container)) {
return null;
}
if (!barScale) {
return null;
}
return <BarYAxisInner {...props} container={container} />;
}
const BarYAxisInner = memo(function BarYAxisInner({
showAllLabels = true,
maxLabels = 20,
container,
}: BarYAxisProps & { container: HTMLDivElement }) {
const { margin, barScale, bandWidth, barXAccessor, data, hoveredBarIndex } =
useChart();
// Generate labels for each bar
const labelsToShow = useMemo(() => {
if (!(barScale && bandWidth && barXAccessor)) {
return [];
}
const allLabels = data.map((d, i) => {
const label = barXAccessor(d);
const bandY = barScale(label) ?? 0;
// Center the label vertically within the band
const y = bandY + margin.top;
return { label, y, bandHeight: bandWidth, index: i };
});
// If showAllLabels is true or we have fewer than maxLabels, show all
if (showAllLabels || allLabels.length <= maxLabels) {
return allLabels;
}
// Otherwise, skip some labels to avoid crowding
const step = Math.ceil(allLabels.length / maxLabels);
return allLabels.filter((_, i) => i % step === 0);
}, [
barScale,
bandWidth,
barXAccessor,
data,
margin.top,
showAllLabels,
maxLabels,
]);
return createPortal(
<div
className="pointer-events-none absolute top-0 bottom-0"
style={{
left: 0,
width: margin.left,
}}
>
{labelsToShow.map((item) => (
<BarYAxisLabel
bandHeight={item.bandHeight}
isHovered={hoveredBarIndex === item.index}
key={`${item.label}-${item.y}`}
label={item.label}
y={item.y}
/>
))}
</div>,
container
);
});
BarYAxis.displayName = "BarYAxis";
export default BarYAxis;
src/charts/loading-sweep.tsx
"use client";
import { scaleLinear } from "@visx/scale";
import { AreaClosed, LinePath } from "@visx/shape";
import { motion, useReducedMotion } from "motion/react";
import {
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { chartCssVars, useChartStable } from "./chart-context";
import {
LINE_LOADING_PULSE_EASE,
LOADING_LABEL_EXIT_S,
} from "./line-loading-timing";
/**
* Shared "sweep" loading visuals. A soft diagonal shimmer band travels across a
* self-contained skeleton silhouette on a loop, painted via an SVG mask. The
* silhouette re-randomizes between passes (held steady during a pass, re-rolled
* once the band clears the right edge) so it reads as live and still loading,
* without warping mid-sweep. Used as the `loadingStyle="sweep"` alternative to
* the traveling pulse on `<Line>` and `<Area>`, and as the skeleton for
* `<BarChart status="loading">`.
*/
// CurveFactory type - simplified version compatible with visx
// biome-ignore lint/suspicious/noExplicitAny: d3 curve factory type
type CurveFactory = any;
/** One shimmer sweep, in seconds. */
const DEFAULT_SWEEP_DURATION_S = 2;
/** Sweep travel in objectBoundingBox space: off the left edge to off the right. */
const SWEEP_START_X = -1;
const SWEEP_END_X = 2;
/** Diagonal tilt of the shimmer band, in degrees. */
const SWEEP_ANGLE_DEG = 25;
const HEIGHT_MIN_PCT = 20;
const HEIGHT_MAX_PCT = 80;
const DEFAULT_POINT_COUNT = 14;
const BAR_CORNER_RADIUS = 2;
const DEFAULT_BAR_COUNT = 12;
const DEFAULT_FILL = "var(--foreground)";
const DEFAULT_BAR_FILL_OPACITY = 0.45;
const LINE_STROKE_OPACITY = 0.55;
const AREA_FILL_TOP_OPACITY = 0.18;
const AREA_FILL_BOTTOM_OPACITY = 0.02;
/** Bar width as a fraction of its band (the rest is the inter-bar gap). */
const DEFAULT_BAR_FRACTION = 0.7;
// ─── Pure, SSR-safe helpers ──────────────────────────────────────────────
// Heights come from a deterministic hash of (index, seed), never
// `Math.random()`, so the first server render and first client render agree
// (no Next.js hydration mismatch). Re-randomizing only bumps the numeric seed
// on the client, after a sweep completes.
/** Cheap deterministic hash to a fractional part in [0, 1). */
function hashFract(n: number): number {
const x = Math.sin(n) * 43_758.5453;
return x - Math.floor(x);
}
/** Deterministic heights (percentages of the available height) for a seed. */
export function getSkeletonHeights(
count: number,
seed = 0,
min = HEIGHT_MIN_PCT,
max = HEIGHT_MAX_PCT
): number[] {
const range = max - min;
return Array.from(
{ length: count },
(_, i) => min + Math.floor(hashFract((i + 1) * 12.9898 + seed) * range)
);
}
/** Deterministic up/down (±1) per bar for the "center" baseline. */
function getSkeletonSigns(count: number, seed = 0): number[] {
return Array.from({ length: count }, (_, i) =>
hashFract((i + 1) * 78.233 + seed) < 0.5 ? -1 : 1
);
}
/** Bell-curve opacity stops (sin squared) for the shimmer band's soft edges. */
function generateEasedGradientStops(
steps = 17,
minOpacity = 0.05,
maxOpacity = 0.9
) {
return Array.from({ length: steps }, (_, i) => {
const t = i / (steps - 1);
const eased = Math.sin(t * Math.PI) ** 2;
const opacity = minOpacity + eased * (maxOpacity - minOpacity);
return {
offset: `${(t * 100).toFixed(0)}%`,
opacity: Number(opacity.toFixed(3)),
};
});
}
// ─── Shared mask defs (`${chartId}-mask` is the mask id) ─────────────────────
function LoadingSweepMask({
chartId,
width,
height,
durationSeconds,
onSweepComplete,
}: {
chartId: string;
width: number;
height: number;
durationSeconds: number;
onSweepComplete: () => void;
}) {
const gradientStops = useMemo(() => generateEasedGradientStops(), []);
const lastXRef = useRef(SWEEP_START_X);
const handleUpdate = useCallback(
(latest: { x?: number }) => {
const xValue = typeof latest.x === "number" ? latest.x : SWEEP_START_X;
// Re-roll once the band has cleared the visible area (crossed past 1),
// so the silhouette never changes shape under the user's eye.
if (xValue >= 1 && lastXRef.current < 1) {
onSweepComplete();
}
lastXRef.current = xValue;
},
[onSweepComplete]
);
return (
<>
<linearGradient id={`${chartId}-grad`} x1="0" x2="1" y1="0" y2="0">
{gradientStops.map(({ offset, opacity }) => (
<stop
key={offset}
offset={offset}
stopColor="white"
stopOpacity={opacity}
/>
))}
</linearGradient>
<pattern
height="1"
id={`${chartId}-pattern`}
patternContentUnits="objectBoundingBox"
patternTransform={`rotate(${SWEEP_ANGLE_DEG})`}
patternUnits="objectBoundingBox"
width={3}
x="0"
y="0"
>
<motion.rect
animate={{ x: SWEEP_END_X }}
fill={`url(#${chartId}-grad)`}
height="1"
initial={{ x: SWEEP_START_X }}
onUpdate={handleUpdate}
transition={{
duration: durationSeconds,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
}}
width="1"
y="0"
/>
</pattern>
<mask id={`${chartId}-mask`} maskUnits="userSpaceOnUse">
<rect fill={`url(#${chartId}-pattern)`} height={height} width={width} />
</mask>
</>
);
}
// ─── Line / Area loading sweep (its own re-randomizing silhouette) ───────────
export interface LineLoadingSweepProps {
/** Curve factory from the host `<Line>` / `<Area>`, so the silhouette matches
* the chart's interpolation (step, smooth, linear, …). */
curve: CurveFactory;
/** Fill the silhouette as an area (for `<Area>`); otherwise stroke only. */
withArea?: boolean;
/** Loading phase: `"loop"` (steady), `"exit"` (loading → ready), or `"enter"`
* (ready → loading). Exit/enter fade the silhouette and then signal the chart
* to continue its reveal. Default: `"loop"`. */
mode?: "loop" | "exit" | "enter";
/** Fired when an exit/enter transition finishes, to advance the chart phase. */
onTransitionComplete?: () => void;
stroke?: string;
strokeOpacity?: number;
strokeWidth?: number;
pointCount?: number;
durationSeconds?: number;
}
/**
* Renders a placeholder line/area silhouette (its own, not the chart's skeleton)
* with the shimmer sweeping across it. The silhouette re-randomizes between
* passes. Reads inner dimensions from chart context.
*/
export function LineLoadingSweep({
curve,
withArea = false,
mode = "loop",
onTransitionComplete,
stroke = chartCssVars.foreground,
strokeOpacity = LINE_STROKE_OPACITY,
strokeWidth = 2,
pointCount = DEFAULT_POINT_COUNT,
durationSeconds = DEFAULT_SWEEP_DURATION_S,
}: LineLoadingSweepProps) {
const { innerWidth, innerHeight } = useChartStable();
const reduceMotion = useReducedMotion();
const reactId = useId();
const chartId = `line-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const isLoop = mode === "loop";
const [tick, setTick] = useState(0);
// Re-randomize only while looping; hold the silhouette steady through a
// transition so it fades out (or in) as one piece.
const onSweepComplete = useCallback(() => {
if (isLoop) {
setTick((prev) => prev + 1);
}
}, [isLoop]);
const heights = useMemo(
() => getSkeletonHeights(pointCount, tick),
[pointCount, tick]
);
// With reduced motion there is no fade to await, so signal the handoff
// immediately or the phase machine would stall mid-transition.
useEffect(() => {
if (reduceMotion && !isLoop) {
onTransitionComplete?.();
}
}, [reduceMotion, isLoop, onTransitionComplete]);
if (innerWidth <= 0 || innerHeight <= 0 || heights.length < 2) {
return null;
}
const xScale = scaleLinear({
domain: [0, heights.length - 1],
range: [0, innerWidth],
});
const yScale = scaleLinear({ domain: [0, 100], range: [innerHeight, 0] });
const points = heights.map((value, index) => ({ index, value }));
const getX = (d: { index: number }) => xScale(d.index);
const getY = (d: { value: number }) => yScale(d.value);
const silhouette = (
<>
{withArea ? (
<AreaClosed
curve={curve}
data={points}
fill={`url(#${chartId}-area)`}
x={getX}
y={getY}
yScale={yScale}
/>
) : null}
<LinePath
curve={curve}
data={points}
fill="none"
stroke={stroke}
strokeLinecap="round"
strokeOpacity={strokeOpacity}
strokeWidth={strokeWidth}
x={getX}
y={getY}
/>
</>
);
const areaGradient = withArea ? (
<linearGradient id={`${chartId}-area`} x1="0" x2="0" y1="0" y2="1">
<stop
offset="0%"
stopColor={stroke}
stopOpacity={AREA_FILL_TOP_OPACITY}
/>
<stop
offset="100%"
stopColor={stroke}
stopOpacity={AREA_FILL_BOTTOM_OPACITY}
/>
</linearGradient>
) : null;
if (reduceMotion) {
return (
<>
{areaGradient ? <defs>{areaGradient}</defs> : null}
{silhouette}
</>
);
}
const maskUrl = `url(#${chartId}-mask)`;
const defs = (
<defs>
{areaGradient}
<LoadingSweepMask
chartId={chartId}
durationSeconds={durationSeconds}
height={innerHeight}
onSweepComplete={onSweepComplete}
width={innerWidth}
/>
</defs>
);
if (isLoop) {
return (
<>
{defs}
<g mask={maskUrl}>{silhouette}</g>
</>
);
}
// Transition: fade the swept silhouette out (exit) or in (enter), then hand
// off to the chart so it can reveal the real series.
return (
<>
{defs}
<motion.g
animate={{ opacity: mode === "exit" ? 0 : 1 }}
initial={{ opacity: mode === "exit" ? 1 : 0 }}
mask={maskUrl}
onAnimationComplete={onTransitionComplete}
transition={{
duration: LOADING_LABEL_EXIT_S,
ease: [...LINE_LOADING_PULSE_EASE],
}}
>
{silhouette}
</motion.g>
</>
);
}
LineLoadingSweep.displayName = "LineLoadingSweep";
// ─── Bar loading skeleton (seeded bars under the sweep, inner coords) ─────────
function SkeletonBars({
heights,
signs,
innerWidth,
innerHeight,
baseline,
barFraction,
fill,
fillOpacity,
}: {
heights: number[];
signs: number[];
innerWidth: number;
innerHeight: number;
baseline: "bottom" | "center";
barFraction: number;
fill: string;
fillOpacity: number;
}) {
const bandWidth = innerWidth / heights.length;
const barW = bandWidth * barFraction;
const xOffset = (bandWidth * (1 - barFraction)) / 2;
const isCenter = baseline === "center";
const baselineY = isCenter ? innerHeight / 2 : innerHeight;
const halfBarH = isCenter ? innerHeight / 2 : innerHeight;
return (
<>
{heights.map((value, i) => {
const sign = isCenter ? (signs[i] ?? 1) : 1;
const barH = Math.max(1, halfBarH * (value / 100));
const x = i * bandWidth + xOffset;
const y = sign === 1 ? baselineY - barH : baselineY;
return (
<rect
fill={fill}
fillOpacity={fillOpacity}
height={barH}
key={`${x.toFixed(2)}-${value}`}
rx={BAR_CORNER_RADIUS}
width={barW}
x={x}
y={y}
/>
);
})}
</>
);
}
export interface BarLoadingSkeletonProps {
innerWidth: number;
innerHeight: number;
/** Number of skeleton bars. Default: 12 */
barCount?: number;
/** Bar fill color. Default: `var(--foreground)` */
fill?: string;
/** Bar fill opacity. Default: 0.45 */
fillOpacity?: number;
/** Bars rise from the bottom or diverge from the vertical center. Default: `"bottom"` */
baseline?: "bottom" | "center";
/** Bar width as a fraction of its band (0–1). Default: 0.7 */
barFraction?: number;
/** One shimmer sweep, in seconds. Default: 2 */
durationSeconds?: number;
}
/**
* Skeleton bars masked by the shimmer sweep, re-randomizing between passes.
* Rendered in the chart's inner coordinate space (origin at the inner top-left),
* so a `BarChart` drops it inside its margin-translated group.
*/
export function BarLoadingSkeleton({
innerWidth,
innerHeight,
barCount = DEFAULT_BAR_COUNT,
fill = DEFAULT_FILL,
fillOpacity = DEFAULT_BAR_FILL_OPACITY,
baseline = "bottom",
barFraction = DEFAULT_BAR_FRACTION,
durationSeconds = DEFAULT_SWEEP_DURATION_S,
}: BarLoadingSkeletonProps) {
const reduceMotion = useReducedMotion();
const reactId = useId();
const chartId = `bar-sweep-${reactId.replace(/[^a-zA-Z0-9_-]/g, "")}`;
const [tick, setTick] = useState(0);
const onSweepComplete = useCallback(() => setTick((prev) => prev + 1), []);
const heights = useMemo(
() => getSkeletonHeights(barCount, tick),
[barCount, tick]
);
const signs = useMemo(
() => getSkeletonSigns(barCount, tick),
[barCount, tick]
);
if (innerWidth <= 0 || innerHeight <= 0) {
return null;
}
const bars = (
<SkeletonBars
barFraction={barFraction}
baseline={baseline}
fill={fill}
fillOpacity={fillOpacity}
heights={heights}
innerHeight={innerHeight}
innerWidth={innerWidth}
signs={signs}
/>
);
if (reduceMotion) {
return bars;
}
return (
<>
<defs>
<LoadingSweepMask
chartId={chartId}
durationSeconds={durationSeconds}
height={innerHeight}
onSweepComplete={onSweepComplete}
width={innerWidth}
/>
</defs>
<g mask={`url(#${chartId}-mask)`}>{bars}</g>
</>
);
}
BarLoadingSkeleton.displayName = "BarLoadingSkeleton";
src/charts/bar-chart-loading.tsx
"use client";
import { BarChart } from "./bar-chart";
import type { Margin } from "./chart-context";
const EMPTY_DATA: Record<string, unknown>[] = [];
export interface BarChartLoadingProps {
/** Chart margins. */
margin?: Partial<Margin>;
/** Aspect ratio as "width / height". Default: "2 / 1" */
aspectRatio?: string;
/** Additional class name for the container. */
className?: string;
}
/**
* Turnkey loading skeleton for bar charts, a thin shortcut for
* `<BarChart status="loading" />`. Renders shimmer-swept placeholder bars while
* data is fetching; swap in a real `<BarChart>` once it resolves.
*/
export function BarChartLoading({
margin,
aspectRatio = "2 / 1",
className = "",
}: BarChartLoadingProps) {
return (
<BarChart
aspectRatio={aspectRatio}
className={className}
data={EMPTY_DATA}
margin={margin}
status="loading"
/>
);
}
export default BarChartLoading;