Bklit UI
componentbar-depthsource: ok · hit
Bar Depth
3D depth and glossy glass surfaces for BarChart bars
npx shadcn add "https://ui.bklit.com/r/bar-depth.json"free
Visual style
glass-blur3d
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumanimationmotion
Registry dependencies
@bklit/bar-chart
Files1 · source cached 10/9/2026, 9:30:18 PM
src/charts/bar-depth.tsx
"use client";
/**
* Bar 3D depth + glass surfaces — drop-in layers for `<BarChart>`.
*
* Adds a head-on perspective and a glossy glass-block sheen to the bars in
* any `<BarChart>`. Bars left of chart center expose their RIGHT side face;
* bars right of center expose their LEFT — the vanishing point sits at the
* chart's horizontal middle, so the row reads like vertical glass blocks
* viewed straight on.
*
* Three surfaces per bar:
* 1. Side face — a parallelogram on the depth side, lit at the front edge
* with a horizontal black gradient and shaded toward the back.
* 2. Top face — a parallelogram lid above the bar's top edge, with a
* bright catch at the front lip fading to soft white at the back.
* 3. Glass overlay — a vertical white-to-dark gradient on the bar's front
* face (sharp top reflection, transparent middle, faint bottom shadow).
*
* Usage (purely additive — no changes to `<Bar>`):
*
* <BarChart data={data} xDataKey="month">
* <Grid horizontal />
* <BarDepthBack dataKey="value" color="var(--chart-1)" /> // before <Bar>
* <Bar dataKey="value" fill="var(--chart-1)" />
* <BarDepthFront dataKey="value" /> // after <Bar>
* </BarChart>
*
* Both depth layers read geometry from `useChart()` and figure out the
* perspective themselves. The only configuration is the bar color
* (`color` / `colorAccessor`) and, for stacked bars, a `segmentsAccessor`
* supplied via `<BarDepthProvider>`.
*
* An optional `<BarPulse>` paints a looping vertical sweep over a single
* "active" bar (selected by `activeIndex`) — useful for highlighting a live
* or in-progress value. Render it AFTER `<BarDepthFront>`.
*
* Behavior notes:
* - Negative values are supported: a bar whose value is below zero grows
* downward from the baseline, with the lid sitting on the baseline.
* (This requires the host `<BarChart>` to use a value scale whose
* domain includes negatives.)
* - Zero-value bars are skipped by default; set `minBarHeight` on
* `<BarDepthProvider>` (and `<Bar>`) to floor them to a short visible bar.
*
* Performance:
* - Three shared SVG gradients per layer instance (ID-collision-safe via
* `useId()`).
* - A per-bar `<clipPath>` for the side face animates from the baseline so
* the static side path is revealed bottom-up in lockstep with the bar.
* Only used where depth ≥ 0.5px.
* - At high bar density the column gap clamps depth below the 0.5px
* threshold; the back surfaces skip rendering and only the cheap glass
* rect remains.
* - `useBarDepthEntries` is memoized, so the Back/Front/Pulse layers
* compute geometry at most once per render between them.
*/
import { motion, type Transition } from "motion/react";
import {
createContext,
type ReactNode,
useContext,
useId,
useMemo,
} from "react";
import { barDepthAndRise, barDepthMaxDepth } from "./bar-depth-geometry";
import { useChartHover, useChartStable } from "./chart-context";
import { transitionWithDelay } from "./motion-utils";
// ─── Constants ───────────────────────────────────────────────────────
/** Below this depth the back surfaces don't render — saves elements for
* dead-center bars and dense charts where perspective is sub-pixel. */
const BAR_DEPTH_MIN_PX = 0.5;
/** Mirrors `Bar`'s default `fadedOpacity` so depth/glass dim in step with
* their bar when a sibling bar is hovered. */
const BAR_FADED_OPACITY = 0.3;
/** Default bar color when neither `color` nor `colorAccessor` is provided. */
const DEFAULT_BAR_COLOR = "var(--chart-line-primary)";
/** Default opacity of the baseline "contact shadow" that grounds each bar at
* the zero line. Configurable via `<BarDepthProvider groundShadow>`. */
const DEFAULT_GROUND_SHADOW = 0.26;
/** Peak opacity of the white reflection catch at each bar's tip. Kept here so
* the front/side glass and the solid lid highlight all use the same value. */
const GLASS_TIP_OPACITY = 0.2;
// `BarPulse` — vertical sweep wave constants. The wave is a tall translucent
// rect with a centered bright band; it translates from below the bar's bottom
// edge to above the lid's back edge on a loop, clipped to the bar's full 3D
// silhouette.
/** Wave height = max(barHeight × this, PULSE_WAVE_HEIGHT_MIN_PX). 0.55 puts
* the bright band's peak in the middle of the bar at mid-travel. */
const PULSE_WAVE_HEIGHT_RATIO = 0.55;
/** Minimum wave height in px so short bars still pulse on a steady cadence —
* the wave is taller than the bar so the bright band sweeps through cleanly
* instead of flashing for one frame. */
const PULSE_WAVE_HEIGHT_MIN_PX = 36;
/** Seconds per sweep (bottom → above the lid). 2.4s reads as a deliberate
* heartbeat; faster feels frantic, slower feels laggy. */
const PULSE_WAVE_DURATION_S = 2.4;
/** Peak opacity of the wave's brightest gradient stop. White at 0.85 is
* bright enough to register over saturated bar colors while the bell-curve
* stops around it keep the falloff soft so the bar's hue still dominates. */
const PULSE_WAVE_PEAK_OPACITY = 0.85;
const BAR_HOVER_TRANSITION = { duration: 0.15, ease: "easeOut" as const };
// ─── Types ───────────────────────────────────────────────────────────
export interface BarDepthEntry {
label: string;
/** Index in the original `data` array — used for stagger calc. NOT the
* index in this entries list. */
dataIndex: number;
/** Original datum reference — handed back to `colorAccessor` /
* `segmentsAccessor` so consumers can color or split bars without
* bar-depth knowing the data shape. */
datum: Record<string, unknown>;
/** True when this bar is the active bar (`dataIndex === activeIndex`).
* Used only by `<BarPulse>`; geometry is identical regardless. */
isActive: boolean;
/** True when the bar's value is negative — the bar extends downward from
* the baseline. The lid still sits at the baseline; the side parallelogram
* grows downward instead of upward. */
isNegative: boolean;
/** y of the chart's baseline (= `yScale(0)`). For positive bars this equals
* `bottomY`; for negative bars it equals `topY`. Used as the grow-from
* anchor for both the clipPath (side face) and the glass rect. */
baselineY: number;
bandX: number;
bandWidth: number;
/** Visual top of the bar in screen Y (smaller y = higher on screen). */
topY: number;
/** Visual bottom of the bar in screen Y. */
bottomY: number;
barHeight: number;
/** Un-trimmed natural pixel height of the bar (= `|baselineY - yScale(value)|`).
* Segment heights are scaled against this so they line up with `<Bar>`. */
naturalHeight: number;
/** Pixels subtracted from the bar's visual top (and added to `topY`) so the
* lid's back edge lands at `yScale(value)` regardless of depth. */
topYTrim: number;
/** Side-face thickness in px (0 = dead-center bar / dense chart). */
depth: number;
perspectiveRise: number;
isRightOfCenter: boolean;
}
/** One segment of a stacked bar, in stack order bottom→top. The values across
* all segments should sum to the bar's value; bar-depth uses the proportions
* to pick segment boundaries that line up with the front face's stacking. */
export interface BarDepthSegment {
value: number;
color: string;
}
/** Resolve a per-datum color, falling back to the shared `color` default. */
type ColorAccessor = (datum: Record<string, unknown>, index: number) => string;
function resolveColor(
datum: Record<string, unknown>,
index: number,
color: string,
colorAccessor?: ColorAccessor
): string {
return colorAccessor?.(datum, index) ?? color;
}
/** Side-face parallelogram between two screen-Y edges, on the depth side of
* the bar (left of the bar for right-of-center bars, right for left-of). */
function sideFacePath(
e: BarDepthEntry,
topEdge: number,
bottomEdge: number
): string {
const rise = e.perspectiveRise;
if (e.isRightOfCenter) {
const x = e.bandX;
return `M ${x} ${topEdge} L ${x - e.depth} ${topEdge - rise} L ${x - e.depth} ${bottomEdge - rise} L ${x} ${bottomEdge} Z`;
}
const x = e.bandX + e.bandWidth;
return `M ${x} ${topEdge} L ${x + e.depth} ${topEdge - rise} L ${x + e.depth} ${bottomEdge - rise} L ${x} ${bottomEdge} Z`;
}
/** Lid (top face) parallelogram above the bar's top edge. */
function lidFacePath(e: BarDepthEntry): string {
const rise = e.perspectiveRise;
const left = e.bandX;
const right = e.bandX + e.bandWidth;
if (e.isRightOfCenter) {
return `M ${left} ${e.topY} L ${right} ${e.topY} L ${right - e.depth} ${e.topY - rise} L ${left - e.depth} ${e.topY - rise} Z`;
}
return `M ${left} ${e.topY} L ${right} ${e.topY} L ${right + e.depth} ${e.topY - rise} L ${left + e.depth} ${e.topY - rise} Z`;
}
/**
* Build the side-face parallelograms for a bar. Without segments it's a single
* piece in `baseColor`; with segments each gets its natural scaled height
* `(seg.value/total) * naturalHeight` and only the topmost has `topYTrim`
* removed — matching exactly how `<Bar>` stacks segments, so the side seams
* line up with the front face.
*/
function buildSidePieces(
e: BarDepthEntry,
visibleSegments: BarDepthSegment[] | null,
baseColor: string
): { path: string; color: string }[] {
if (!(visibleSegments && visibleSegments.length > 0)) {
return [{ path: sideFacePath(e, e.topY, e.bottomY), color: baseColor }];
}
const totalValue = visibleSegments.reduce((sum, s) => sum + s.value, 0);
const pieces: { path: string; color: string }[] = [];
let cursorY = e.bottomY;
for (let i = 0; i < visibleSegments.length; i++) {
const seg = visibleSegments[i];
if (!seg) {
continue;
}
const isTopmost = i === visibleSegments.length - 1;
const segScaledHeight = (seg.value / totalValue) * e.naturalHeight;
const segHeight = isTopmost
? Math.max(0, segScaledHeight - e.topYTrim)
: segScaledHeight;
const segBottomY = cursorY;
const segTopY = cursorY - segHeight;
cursorY = segTopY;
pieces.push({
path: sideFacePath(e, segTopY, segBottomY),
color: seg.color,
});
}
return pieces;
}
// ─── Context ──────────────────────────────────────────────────────────
// `BarDepthProvider` is the single source of truth for `segmentsAccessor`,
// shared by every depth surface (`<BarDepthBack>`, `<BarDepthFront>`,
// `<BarPulse>`) AND by the entries hook those surfaces consume.
//
// Why context instead of per-component props: `useBarDepthEntries` computes
// `topYTrim` from the topmost segment's scaled height, so all three layers
// need the *same* `segmentsAccessor` to produce the same `topY`. A single
// provider above the chart makes it structurally impossible for them to
// disagree.
//
// `BarChart` reads its `<Bar>` children with `React.Children.forEach`, which
// doesn't recurse into a provider. Wrap the provider *around* the
// `<BarChart>`, not inside it.
interface BarDepthContextValue {
segmentsAccessor?: (
datum: Record<string, unknown>
) => BarDepthSegment[] | null | undefined;
/** Opacity (0–1) of the baseline contact shadow. 0 removes it. */
groundShadow?: number;
/** Minimum bar height in px — floors short/zero bars so they stay visible.
* Match the value passed to `<Bar minBarHeight>`. */
minBarHeight?: number;
}
const BarDepthContext = createContext<BarDepthContextValue>({});
export interface BarDepthProviderProps extends BarDepthContextValue {
children: ReactNode;
}
/**
* Provides shared bar-depth configuration to every depth surface beneath it.
* Wrap a `<BarChart>` whose children include `<BarDepthBack>` /
* `<BarDepthFront>` / `<BarPulse>` with this provider to:
* - split stacked bars into per-segment side faces (`segmentsAccessor`),
* - tune the baseline contact shadow (`groundShadow`, default 0.26; 0 = off),
* - floor short/zero bars so they stay visible (`minBarHeight`; pair with the
* same value on `<Bar minBarHeight>`).
*
* Because the glass gradient is chart-wide (one Y-anchored ramp shared by the
* front face and the side glass), the provider is the single source of truth —
* so the surfaces can never disagree.
*/
export function BarDepthProvider({
segmentsAccessor,
groundShadow,
minBarHeight,
children,
}: BarDepthProviderProps) {
// Memoize so consumers don't re-render when the config is stable.
const value = useMemo<BarDepthContextValue>(
() => ({ segmentsAccessor, groundShadow, minBarHeight }),
[segmentsAccessor, groundShadow, minBarHeight]
);
return (
<BarDepthContext.Provider value={value}>
{children}
</BarDepthContext.Provider>
);
}
BarDepthProvider.displayName = "BarDepthProvider";
// ─── Per-bar glass gradient ──────────────────────────────────────────
// The glass is anchored to EACH BAR's own height (objectBoundingBox: 0% = the
// bar's tip, 100% = its baseline), not to the chart. So every bar — tall or
// short — gets the identical proportional gloss. (Anchoring per-chart made a
// tall bar's tip bright while a short bar's tip fell in the transparent middle
// and read as matte.)
interface GlassGradientStop {
offset: string;
color: string;
opacity: string;
}
/**
* Stop list for a POSITIVE bar's glass (0% = tip, 100% = baseline): a sharp
* white reflection catch at the tip falling off fast to transparent, then the
* contact shadow at the base.
*/
function buildPosBarStops(groundShadow: number): GlassGradientStop[] {
return [
{ offset: "0%", color: "white", opacity: String(GLASS_TIP_OPACITY) },
{ offset: "3%", color: "white", opacity: "0.09" },
{ offset: "8%", color: "white", opacity: "0.02" },
{ offset: "55%", color: "white", opacity: "0" },
{ offset: "100%", color: "black", opacity: String(groundShadow) },
];
}
/**
* Mirror of `buildPosBarStops` for NEGATIVE bars (which grow downward): the
* contact shadow sits at the top (the baseline) and the reflection catch at
* the bottom (the bar's tip).
*/
function buildNegBarStops(groundShadow: number): GlassGradientStop[] {
return [
{ offset: "0%", color: "black", opacity: String(groundShadow) },
{ offset: "45%", color: "white", opacity: "0" },
{ offset: "92%", color: "white", opacity: "0.02" },
{ offset: "97%", color: "white", opacity: "0.09" },
{ offset: "100%", color: "white", opacity: String(GLASS_TIP_OPACITY) },
];
}
/**
* Build both per-bar glass stop lists from the current `groundShadow`. Each
* depth surface picks `posStops` or `negStops` by the bar's direction and
* paints it objectBoundingBox, so the gloss is identical across bar heights.
*/
function useGlassStops() {
const { groundShadow = DEFAULT_GROUND_SHADOW } = useContext(BarDepthContext);
return useMemo(
() => ({
posStops: buildPosBarStops(groundShadow),
negStops: buildNegBarStops(groundShadow),
}),
[groundShadow]
);
}
function readBarValue(d: Record<string, unknown>, key: string): number {
const v = key ? d[key] : undefined;
return typeof v === "number" ? v : 0;
}
// ─── Hooks ───────────────────────────────────────────────────────────
/**
* Compute per-bar 3D depth geometry from chart context.
*
* Memoized on the chart's scales + data, so calling this from sibling layers
* within the same chart is essentially free after the first settles. Returns
* one entry per renderable bar; bars with no resolvable height are excluded.
*
* Negative-value bars are kept — `isNegative` flips the entry's geometry so
* the side parallelogram and lid render in the correct direction.
*
* Exported for advanced consumers that want to render custom layers aligned
* with the same geometry (e.g. labels above each bar's top face).
*/
export function useBarDepthEntries(
dataKey: string,
activeIndex?: number
): BarDepthEntry[] {
const {
data,
barScale,
bandWidth,
yScale,
innerHeight,
innerWidth,
barXAccessor,
} = useChartStable();
// Read shared config from `<BarDepthProvider>`. Outside a provider these are
// the defaults (no segmentation, no floor) — correct for simple charts.
const { segmentsAccessor, minBarHeight = 0 } = useContext(BarDepthContext);
return useMemo(() => {
if (!(barScale && bandWidth && barXAccessor)) {
return [];
}
const centerX = innerWidth / 2;
const zeroY = yScale(0) ?? innerHeight;
// d3-scaleBand exposes step() (= bandwidth + gap); the shared helper caps
// depth below the gap so a side face never spills past the next bar.
const step =
(barScale as unknown as { step?: () => number }).step?.() ?? bandWidth;
const maxDepth = barDepthMaxDepth(step, bandWidth);
// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: per-bar 3D geometry (direction, perspective trim, segment scaling) reads clearest as one pass
return data.flatMap((d, dataIndex): BarDepthEntry[] => {
// Stacked bars: the bar's value is the SUM of its segments (so the depth
// height matches the host BarChart's stacked total). Single bars: the
// `dataKey` column. `segmentsAccessor` comes from `<BarDepthProvider>`.
const visibleSegs =
segmentsAccessor?.(d)?.filter((s) => s.value > 0) ?? null;
const value =
visibleSegs && visibleSegs.length > 0
? visibleSegs.reduce((sum, s) => sum + s.value, 0)
: readBarValue(d, dataKey);
const isActive = activeIndex != null && dataIndex === activeIndex;
const isNegative = value < 0;
const label = barXAccessor(d);
const bandX = barScale(label) ?? 0;
const cx = bandX + bandWidth / 2;
// `Math.abs` so negative bars produce a positive height — direction is
// captured by `isNegative`. Non-negative bars are floored to
// `minBarHeight` so short/zero values stay visible (mirrors
// `<Bar minBarHeight>`); a floored bar skips the perspective trim so its
// tiny front face and lid stay aligned. With the default floor of 0,
// zero-value bars produce no height and are skipped.
const valuePos = yScale(value) ?? innerHeight;
const rawHeight = Math.abs(zeroY - valuePos);
const naturalHeight = isNegative
? rawHeight
: Math.max(rawHeight, minBarHeight);
if (naturalHeight <= 0) {
return [];
}
const isFloored = !isNegative && rawHeight < naturalHeight;
const offsetFromCenter = centerX > 0 ? (cx - centerX) / centerX : 0;
const isRightOfCenter = offsetFromCenter > 0;
const absOffset = Math.min(1, Math.abs(offsetFromCenter));
const { depth, perspectiveRise: rawRise } = barDepthAndRise(
absOffset,
naturalHeight,
maxDepth
);
// Perspective lift applies only to positive bars. For negative bars a
// lift would raise the lid's back edge above the baseline (a visible
// "lip" at the chart edges), so it's forced to 0 — degenerating the lid
// and flattening the side into a rectangle flush with the baseline.
const perspectiveRise = isNegative ? 0 : rawRise;
// Perspective trim — positive value-bearing bars only. The lid's back
// edge sits `perspectiveRise` above `topY`. Without trim the silhouette
// top would vary with depth (bars further from center look taller).
// Trim shifts `topY` DOWN by `perspectiveRise` so the lid's back edge
// lands on `yScale(value)` regardless of depth. The clamp denominator
// is the topmost segment's scaled height (for stacked bars) or the whole
// bar's natural height, matching how `<Bar>` stacks segments.
let trimClampReference = naturalHeight;
if (visibleSegs && visibleSegs.length > 0) {
const segTotal = visibleSegs.reduce((sum, s) => sum + s.value, 0);
const topmost = visibleSegs.at(-1);
if (segTotal > 0 && topmost) {
trimClampReference = (topmost.value / segTotal) * naturalHeight;
}
}
const isValueBar = naturalHeight > 0 && !isNegative && !isFloored;
const topYTrim = isValueBar
? Math.min(perspectiveRise, Math.max(0, trimClampReference - 1))
: 0;
// Direction-aware top/bottom: positive bars hang ABOVE the baseline;
// negative bars hang BELOW it. The lid always lives at `topY` so for
// negative bars it sits on the baseline.
const topY = isNegative ? zeroY : zeroY - naturalHeight + topYTrim;
const bottomY = isNegative ? zeroY + naturalHeight : zeroY;
const barHeight = bottomY - topY;
return [
{
label,
dataIndex,
datum: d,
isActive,
isNegative,
baselineY: zeroY,
bandX,
bandWidth,
topY,
bottomY,
barHeight,
naturalHeight,
topYTrim,
depth,
perspectiveRise,
isRightOfCenter,
},
];
});
}, [
data,
barScale,
bandWidth,
yScale,
innerHeight,
innerWidth,
barXAccessor,
segmentsAccessor,
minBarHeight,
dataKey,
activeIndex,
]);
}
function useBarDepthStagger() {
const { data, animationDuration } = useChartStable();
// Mirror `Bar`'s stagger formula so each depth surface enters in lockstep
// with its front face (`Bar` uses `animationDuration * 0.4 / count`).
const total = animationDuration || 1100;
return data.length > 1 ? (total * 0.4) / 1000 / data.length : 0;
}
// ─── Components ───────────────────────────────────────────────────────
export interface BarDepthBackProps {
/** Key in data for the bar value. Match the sibling `<Bar dataKey>`. */
dataKey: string;
/** Solid color for the side + top faces. Default: var(--chart-line-primary). */
color?: string;
/** Per-bar color override; takes precedence over `color` when provided. */
colorAccessor?: ColorAccessor;
}
/**
* Renders the back surfaces (side + top faces) of the 3D bar effect.
*
* Place BEFORE the `<Bar>` elements in `<BarChart>` children so the bar's
* solid front face occludes any depth that would extend into an adjacent
* column. Skips bars at chart center (depth ≈ 0) and dense charts.
*
* For stacked bars, wrap the `<BarChart>` in a
* `<BarDepthProvider segmentsAccessor={...}>` so the side face splits into
* per-segment parallelograms and the lid color matches the topmost segment.
*/
export function BarDepthBack({
dataKey,
color = DEFAULT_BAR_COLOR,
colorAccessor,
}: BarDepthBackProps) {
const { isLoaded, enterTransition, revealEpoch = 0 } = useChartStable();
const { hoveredBarIndex } = useChartHover();
const { segmentsAccessor } = useContext(BarDepthContext);
const entries = useBarDepthEntries(dataKey);
const stagger = useBarDepthStagger();
const { posStops: glassStops, negStops: glassNegStops } = useGlassStops();
const idPrefix = useId().replace(/[^a-z0-9]/gi, "_");
// Two horizontal black gradients (one per depth direction) shade the side
// from front-edge (lit) to back-edge (in shadow). A single vertical white
// gradient on the lid: bright catch at the front lip → soft white at the
// back. The vertical glass gradient layered on top of the side adds the same
// bright-tip / dark-baseline curve as the front face for continuity.
const sideShadeRtl = `bdb-side-rtl-${idPrefix}`;
const sideShadeLtr = `bdb-side-ltr-${idPrefix}`;
const topShade = `bdb-top-${idPrefix}`;
const sideGlass = `bdb-glass-${idPrefix}`;
// Per-bar bbox variant used only for bars that extend below the baseline.
const sideGlassNeg = `bdb-glass-neg-${idPrefix}`;
return (
<g className="bar-depth-back" pointerEvents="none">
<defs>
{/* Side shade — diagonal: lit corner at front-top, dark corner at
back-bottom. Right-of-center bars have the side on the LEFT
(gradient runs (1,0)→(0,1)); left-of-center bars mirror it. */}
<linearGradient id={sideShadeRtl} x1="1" x2="0" y1="0" y2="1">
<stop offset="0%" stopColor="black" stopOpacity="0.05" />
<stop offset="100%" stopColor="black" stopOpacity="0.55" />
</linearGradient>
<linearGradient id={sideShadeLtr} x1="0" x2="1" y1="0" y2="1">
<stop offset="0%" stopColor="black" stopOpacity="0.05" />
<stop offset="100%" stopColor="black" stopOpacity="0.55" />
</linearGradient>
{/* Lid 3D shade — mirrors the side face's directionality so the lid
never reads as a brighter "white-washed cap". Front lip at opacity
0 (seamless with the front face), back edge picks up shadow as it
angles into perspective. */}
<linearGradient id={topShade} x1="0" x2="0" y1="1" y2="0">
<stop offset="0%" stopColor="black" stopOpacity="0" />
<stop offset="100%" stopColor="black" stopOpacity="0.18" />
</linearGradient>
{/* Side glass — per-bar (objectBoundingBox): 0% = the bar's tip,
100% = its baseline, same proportional gloss as the front face. */}
<linearGradient id={sideGlass} x1="0" x2="0" y1="0" y2="1">
{glassStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.opacity}`}
offset={stop.offset}
stopColor={stop.color}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
{/* Side glass (per-bar bbox variant for negative bars). The gradient
runs from the bar's baseline-edge (top of bbox) down to the bar's
value tip (bottom of bbox), lifting the tip glow onto each bar's
own value edge. */}
<linearGradient id={sideGlassNeg} x1="0" x2="0" y1="0" y2="1">
{glassNegStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.opacity}`}
offset={stop.offset}
stopColor={stop.color}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
</defs>
{entries.map((e) => {
if (e.depth < BAR_DEPTH_MIN_PX) {
return null;
}
// Resolve stacked segments for this bar. Filter to non-zero so a
// missing segment doesn't paint an invisible 0-height piece or quietly
// become the lid color.
const rawSegments = segmentsAccessor?.(e.datum);
const visibleSegments = rawSegments?.filter((s) => s.value > 0) ?? null;
const baseColor = resolveColor(
e.datum,
e.dataIndex,
color,
colorAccessor
);
const sidePieces = buildSidePieces(e, visibleSegments, baseColor);
// Full-side path for the vertical glass overlay — drawn once per bar
// so the brightness curve sweeps across the entire side as one layer.
const fullSidePath = sideFacePath(e, e.topY, e.bottomY);
// Lid is one parallelogram regardless of segmentation — colored by the
// topmost visible segment so it matches the front face's top section.
const topPath = lidFacePath(e);
const topColor = visibleSegments?.at(-1)?.color ?? baseColor;
const delay = e.dataIndex * stagger;
// Grow uses the SAME enter transition + stagger as `<Bar>` so the side
// and lid rise at the exact speed of the front face. Post-load geometry
// changes get a quick settle instead.
const growTransition = isLoaded
? { duration: 0.3, ease: "easeOut" as const }
: transitionWithDelay(enterTransition, delay);
const isFaded =
hoveredBarIndex != null && hoveredBarIndex !== e.dataIndex;
const sideShadeId = e.isRightOfCenter ? sideShadeRtl : sideShadeLtr;
const safeId = e.label.replace(/[^a-z0-9]/gi, "_");
const clipId = `bdb-clip-${idPrefix}-${safeId}`;
// Lid ENTRANCE only (hover-dim is owned by the wrapping group below, so
// the lid can never dim differently from the side). Positive lids
// translate up from the baseline riding the growing top edge; negative
// lids already sit at the baseline, so they fade in instead.
let lidEntrance: {
initial: false | { y?: number; opacity?: number };
animate: { y?: number; opacity?: number };
transition: Transition;
} = {
initial: false,
animate: { y: 0 },
transition: { y: growTransition },
};
if (!isLoaded && e.isNegative) {
lidEntrance = {
initial: { y: 0, opacity: 0 },
animate: { y: 0, opacity: 1 },
transition: { y: growTransition, opacity: growTransition },
};
} else if (!isLoaded) {
lidEntrance = {
initial: { y: e.barHeight },
animate: { y: 0 },
transition: { y: growTransition },
};
}
return (
// Single hover-dim group for the whole back surface — side AND lid
// share this one opacity so they always fade in lockstep (mirrors
// `<Bar>`, which puts grow on the inner rect and hover-dim on a
// wrapping group). Entrance animations live on the inner groups.
<motion.g
animate={{ opacity: isFaded ? BAR_FADED_OPACITY : 1 }}
initial={false}
key={e.label}
transition={BAR_HOVER_TRANSITION}
>
{/* Side face — a clip-rect that grows from the baseline reveals the
static side path(s) bottom-up, in lockstep with the bar's grow
animation. ClipPath beats CSS scaleY because motion writes the
SVG `transform` directly. */}
<defs>
<clipPath id={clipId}>
{/* Keyed on revealEpoch AND isLoaded so it (a) replays on
every reveal and (b) snaps to final the instant `<Bar>`
swaps to its static rect at isLoaded — so the side never
trails the face past the animation cutoff. */}
<motion.rect
animate={{
y: e.topY - e.perspectiveRise,
height: e.barHeight + e.perspectiveRise,
}}
initial={isLoaded ? false : { y: e.baselineY, height: 0 }}
key={`${clipId}-${revealEpoch}-${isLoaded}`}
transition={growTransition}
width={e.bandWidth + 2 * e.depth + 2}
x={e.bandX - e.depth - 1}
/>
</clipPath>
</defs>
<g clipPath={`url(#${clipId})`}>
{/* Per-segment SOLID colors only. */}
{sidePieces.map((piece) => (
<path d={piece.path} fill={piece.color} key={piece.path} />
))}
{/* Directional shade + Y-anchored glass painted ONCE over the
full side so both gradients stay continuous across segment
seams. Per-segment shading uses objectBoundingBox units, which
would restart the lit→shadowed ramp at every stacked boundary
and read as a notch/indent. The glass is the same global
Y-anchored curve as the front face; negatives swap to the
per-bar bbox variant so the tip catch lands on the bar's own
bottom edge. */}
<path d={fullSidePath} fill={`url(#${sideShadeId})`} />
<path
d={fullSidePath}
fill={`url(#${e.isNegative ? sideGlassNeg : sideGlass})`}
/>
</g>
{/* Top face — three paint passes: solid color → a flat tip-bright
highlight (the lid is the bar's top, so it gets the reflection
catch; it can't share the side's objectBoundingBox glass, which
would map the full ramp onto the lid's own tiny bbox) →
directional 3D shade darkening the back edge. */}
<motion.g
animate={lidEntrance.animate}
initial={lidEntrance.initial}
key={`lid-${safeId}-${revealEpoch}-${isLoaded}`}
transition={lidEntrance.transition}
>
<path d={topPath} fill={topColor} />
<path d={topPath} fill="white" fillOpacity={GLASS_TIP_OPACITY} />
<path d={topPath} fill={`url(#${topShade})`} />
</motion.g>
</motion.g>
);
})}
</g>
);
}
BarDepthBack.displayName = "BarDepthBack";
// Mark as a non-series layer so `BarChart` doesn't count it as a bar (it
// carries a `dataKey` to pair with a Bar). Mirrors `ChartMarkers`'
// `__isChartMarkers` flag — a static property is minification-safe, unlike
// matching on `displayName`.
(BarDepthBack as { __isBarDepthLayer?: boolean }).__isBarDepthLayer = true;
export interface BarDepthFrontProps {
/** Key in data for the bar value. Match the sibling `<Bar dataKey>`. */
dataKey: string;
}
/**
* Renders the front surface: a glossy glass sheen over the bar's solid fill.
* Place AFTER the `<Bar>` elements in `<BarChart>` children so the gradient
* sits on top of the bar.
*
* Reads `segmentsAccessor` from the surrounding `<BarDepthProvider>` (if any)
* via `useBarDepthEntries`, so the glass rectangle's `topY` automatically
* matches the lid+side's trim. No prop wiring required.
*/
export function BarDepthFront({ dataKey }: BarDepthFrontProps) {
const { isLoaded, enterTransition, revealEpoch = 0 } = useChartStable();
const { hoveredBarIndex } = useChartHover();
const entries = useBarDepthEntries(dataKey);
const stagger = useBarDepthStagger();
const { posStops: glassStops, negStops: glassNegStops } = useGlassStops();
const idPrefix = useId().replace(/[^a-z0-9]/gi, "_");
const glassId = `bdf-glass-${idPrefix}`;
const glassIdNeg = `bdf-glass-neg-${idPrefix}`;
return (
<g className="bar-depth-front" pointerEvents="none">
<defs>
{/* Per-bar (objectBoundingBox) glass for POSITIVE bars: 0% = the bar's
tip, 100% = its baseline — same proportional gloss on every bar. */}
<linearGradient id={glassId} x1="0" x2="0" y1="0" y2="1">
{glassStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.opacity}`}
offset={stop.offset}
stopColor={stop.color}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
{/* Per-bar bbox variant for negative bars — the bright catch always
sits on the value-bearing tip no matter how deep the bar reaches. */}
<linearGradient id={glassIdNeg} x1="0" x2="0" y1="0" y2="1">
{glassNegStops.map((stop) => (
<stop
key={`${stop.offset}-${stop.opacity}`}
offset={stop.offset}
stopColor={stop.color}
stopOpacity={stop.opacity}
/>
))}
</linearGradient>
</defs>
{entries.map((e) => {
const delay = e.dataIndex * stagger;
// Same enter transition + stagger as `<Bar>` so the glass rises in
// lockstep with the front face.
const baseTransition = isLoaded
? { duration: 0.3, ease: "easeOut" as const }
: transitionWithDelay(enterTransition, delay);
const isFaded =
hoveredBarIndex != null && hoveredBarIndex !== e.dataIndex;
return (
<motion.rect
animate={{
y: e.topY,
height: e.barHeight,
opacity: isFaded ? BAR_FADED_OPACITY : 1,
}}
fill={`url(#${e.isNegative ? glassIdNeg : glassId})`}
// Anchor the grow at the baseline (not bottomY) so the glass grows
// outward in whichever direction the bar extends. Keyed on
// revealEpoch so it replays the grow on every reveal, like `<Bar>`.
initial={isLoaded ? false : { y: e.baselineY, height: 0 }}
key={`${e.label}-${revealEpoch}-${isLoaded}`}
transition={{ ...baseTransition, opacity: BAR_HOVER_TRANSITION }}
width={e.bandWidth}
x={e.bandX}
/>
);
})}
</g>
);
}
BarDepthFront.displayName = "BarDepthFront";
(BarDepthFront as { __isBarDepthLayer?: boolean }).__isBarDepthLayer = true;
// ─── BarPulse ─────────────────────────────────────────────────────────
/**
* Builds the SVG path tracing the silhouette of a 3D bar — the unioned
* outline of front face + visible side parallelogram + lid parallelogram.
* Used as the clip region for `BarPulse`'s wave so the gradient only paints
* inside the bar's visible 3D shape. For dead-center bars (`depth <= 0`) the
* silhouette degenerates to just the front rectangle.
*/
function buildBarSilhouettePath(e: BarDepthEntry): string {
const {
bandX,
bandWidth,
topY,
bottomY,
depth,
perspectiveRise,
isRightOfCenter,
} = e;
if (depth <= 0) {
return [
`M ${bandX} ${topY}`,
`L ${bandX + bandWidth} ${topY}`,
`L ${bandX + bandWidth} ${bottomY}`,
`L ${bandX} ${bottomY}`,
"Z",
].join(" ");
}
if (isRightOfCenter) {
// Side face on LEFT — back shifted (-depth, -perspectiveRise).
return [
`M ${bandX - depth} ${topY - perspectiveRise}`,
`L ${bandX + bandWidth - depth} ${topY - perspectiveRise}`,
`L ${bandX + bandWidth} ${topY}`,
`L ${bandX + bandWidth} ${bottomY}`,
`L ${bandX} ${bottomY}`,
`L ${bandX - depth} ${bottomY - perspectiveRise}`,
"Z",
].join(" ");
}
// Side face on RIGHT — back shifted (+depth, -perspectiveRise).
return [
`M ${bandX} ${topY}`,
`L ${bandX + depth} ${topY - perspectiveRise}`,
`L ${bandX + bandWidth + depth} ${topY - perspectiveRise}`,
`L ${bandX + bandWidth + depth} ${bottomY - perspectiveRise}`,
`L ${bandX + bandWidth} ${bottomY}`,
`L ${bandX} ${bottomY}`,
"Z",
].join(" ");
}
export interface BarPulseProps {
/** Key in data for the bar value. Match the sibling `<Bar dataKey>`. */
dataKey: string;
/** Index (in the data array) of the bar to pulse. */
activeIndex?: number;
/** Suppress the sweep while keeping the bar's 3D + glass treatment. */
pulsePaused?: boolean;
}
/**
* Vertical wave that sweeps from a bar's root (baseline) toward its tip on a
* continuous loop, painted across the bar's full 3D silhouette (front face +
* lid + visible side). Reads as a heartbeat pulse flowing up the column for
* positive bars and down for negative bars — useful for marking a live or
* in-progress value without adding any fixed element.
*
* Select the bar via `activeIndex`. Set `pulsePaused` to freeze it. Place
* AFTER `<BarDepthFront />` so the wave brightens on top of the glass.
*
* Performance: at most one clipped `<motion.rect>` with a single Y translate
* per frame (no per-frame paint of gradient stops, no SVG filters).
*/
export function BarPulse({ dataKey, activeIndex, pulsePaused }: BarPulseProps) {
const { isLoaded } = useChartStable();
const { hoveredBarIndex } = useChartHover();
const entries = useBarDepthEntries(dataKey, activeIndex);
const idPrefix = useId().replace(/[^a-z0-9]/gi, "_");
const activeEntries = useMemo(
() => entries.filter((e) => e.isActive),
[entries]
);
if (activeEntries.length === 0 || pulsePaused) {
return null;
}
// Hold the wave until bars finish growing — the silhouette clip is computed
// at the final geometry, so painting it mid-grow would reveal a wave inside
// an area the bar hasn't reached yet.
if (!isLoaded) {
return null;
}
return (
<g className="bar-pulse" pointerEvents="none">
<defs>
{activeEntries.flatMap((e) => {
const safeId = e.label.replace(/[^a-z0-9]/gi, "_");
const clipId = `bar-pulse-clip-${idPrefix}-${safeId}`;
const gradId = `bar-pulse-grad-${idPrefix}-${safeId}`;
return [
<clipPath id={clipId} key={`clip-${safeId}`}>
<path d={buildBarSilhouettePath(e)} />
</clipPath>,
// Vertical gradient with a centered bright band shaped into a soft
// bell curve. y1=1, y2=0 makes offset 50% the rect's center, so the
// brightest pixel sits mid-rect during travel.
<linearGradient
id={gradId}
key={`grad-${safeId}`}
x1="0"
x2="0"
y1="1"
y2="0"
>
<stop offset="0%" stopColor="white" stopOpacity="0" />
<stop offset="10%" stopColor="white" stopOpacity="0" />
<stop
offset="22%"
stopColor="white"
stopOpacity={PULSE_WAVE_PEAK_OPACITY * 0.18}
/>
<stop
offset="34%"
stopColor="white"
stopOpacity={PULSE_WAVE_PEAK_OPACITY * 0.5}
/>
<stop
offset="44%"
stopColor="white"
stopOpacity={PULSE_WAVE_PEAK_OPACITY * 0.85}
/>
<stop
offset="50%"
stopColor="white"
stopOpacity={PULSE_WAVE_PEAK_OPACITY}
/>
<stop
offset="56%"
stopColor="white"
stopOpacity={PULSE_WAVE_PEAK_OPACITY * 0.85}
/>
<stop
offset="66%"
stopColor="white"
stopOpacity={PULSE_WAVE_PEAK_OPACITY * 0.5}
/>
<stop
offset="78%"
stopColor="white"
stopOpacity={PULSE_WAVE_PEAK_OPACITY * 0.18}
/>
<stop offset="90%" stopColor="white" stopOpacity="0" />
<stop offset="100%" stopColor="white" stopOpacity="0" />
</linearGradient>,
];
})}
</defs>
{activeEntries.map((e) => {
const safeId = e.label.replace(/[^a-z0-9]/gi, "_");
const clipId = `bar-pulse-clip-${idPrefix}-${safeId}`;
const gradId = `bar-pulse-grad-${idPrefix}-${safeId}`;
const waveHeight = Math.max(
e.barHeight * PULSE_WAVE_HEIGHT_RATIO,
PULSE_WAVE_HEIGHT_MIN_PX
);
// Travel always flows root → tip. Positive bars sweep up (root =
// bottomY); negative bars sweep down (root = topY, the baseline). The
// gradient is symmetric, so reversing the endpoints reverses direction.
const yAboveLid = e.topY - e.perspectiveRise - waveHeight;
const yBelowFloor = e.bottomY;
const yStart = e.isNegative ? yAboveLid : yBelowFloor;
const yEnd = e.isNegative ? yBelowFloor : yAboveLid;
const isFaded =
hoveredBarIndex != null && hoveredBarIndex !== e.dataIndex;
return (
<motion.g
animate={{ opacity: isFaded ? BAR_FADED_OPACITY : 1 }}
clipPath={`url(#${clipId})`}
initial={false}
key={e.label}
transition={BAR_HOVER_TRANSITION}
>
<motion.rect
animate={{ y: yEnd }}
fill={`url(#${gradId})`}
height={waveHeight}
initial={{ y: yStart }}
transition={{
duration: PULSE_WAVE_DURATION_S,
ease: "easeInOut",
repeat: Number.POSITIVE_INFINITY,
repeatType: "loop",
}}
// Span wide enough to cover the bar plus its depth on either
// side; the clipPath crops anything outside the silhouette.
width={e.bandWidth + 2 * e.depth + 2}
x={e.bandX - e.depth - 1}
/>
</motion.g>
);
})}
</g>
);
}
BarPulse.displayName = "BarPulse";
(BarPulse as { __isBarDepthLayer?: boolean }).__isBarDepthLayer = true;