plotcn
componentarea-gradient-depthsource: ok · refreshed
Gradient Depth Area
Single-series area visualization with a semantic fade-to-surface fill that adds restrained depth without encoding an additional variable.
npx shadcn add "https://plotcn.vercel.app/r/area-gradient-depth.json"free
Visual style
gradient
Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.
Dependencies
Bundle weight · mediumchartsrecharts
Registry dependencies
https://plotcn.vercel.app/r/chart-container.jsonhttps://plotcn.vercel.app/r/chart-state.jsonhttps://plotcn.vercel.app/r/chart-tooltip.jsonhttps://plotcn.vercel.app/r/chart-motion.json
Files1 · source cached 10/8/2026, 3:31:37 AM
components/charts/recharts/area-gradient-depth.tsx
"use client"
import * as React from "react"
import {
ResponsiveContainer,
AreaChart,
Area,
XAxis,
YAxis,
Tooltip,
CartesianGrid,
ReferenceLine,
} from "recharts"
import { HugeiconsIcon } from "@hugeicons/react"
import { LockKeyIcon } from "@hugeicons/core-free-icons"
import { useChartReducedMotion } from "../shared/use-chart-reduced-motion"
import { ChartContainer } from "../shared/chart-container"
import {
ChartLoadingState,
ChartEmptyState,
ChartErrorState,
ChartUnavailableState,
} from "../shared/chart-state"
import { cn } from "@/lib/utils"
/* -------------------------------------------------------------------------- */
/* Type Definitions */
/* -------------------------------------------------------------------------- */
export type NumericKeyOf<TData> = [keyof TData] extends [never]
? string
: {
[K in keyof TData]: TData[K] extends number | null | undefined ? K : never
}[keyof TData] extends never
? string
: {
[K in keyof TData]: TData[K] extends number | null | undefined ? K : never
}[keyof TData] & string
export type GradientDepthMode = "surface" | "none"
/**
* Series definition for Gradient Depth Area (single quantitative magnitude series).
*/
export interface GradientDepthAreaSeries<TData extends Record<string, unknown> = Record<string, unknown>> {
/** Property key for quantitative magnitude values */
key: NumericKeyOf<TData>
/** Human-readable display label for legend, tooltips, and screen readers */
label: string
/** Optional custom numeric formatter for metric values */
valueFormatter?: (value: number) => string
}
export interface GradientDepthAreaActiveDatum<
TData extends Record<string, unknown> = Record<string, unknown>,
XVal extends string | number = string | number
> {
index: number
x: XVal
raw: TData
value: number | null
isLocked: boolean
}
export interface GradientDepthAreaProps<
TData extends Record<string, unknown> = Record<string, unknown>,
XVal extends string | number = string | number
> {
/** Readonly array of observation records. Caller data is never mutated. */
data: readonly TData[]
/** Key for horizontal domain coordinate (e.g. date, month, hour). */
xKey: keyof TData & string
/** Semantic series descriptor defining metric key and label. */
series: GradientDepthAreaSeries<TData>
/** Container height in pixels or CSS dimension string. (default: 320) */
height?: number | string
/** Curve interpolation: "monotone" | "linear" | "step". (default: "monotone") */
curve?: "monotone" | "linear" | "step"
/** Explicit Y-axis numeric domain, or "auto" calculation. */
domain?: [number, number] | "auto"
/**
* Baseline reference toward which the area fills.
* - "zero": Fills toward 0 (conventional for non-negative magnitude). Automatic domain includes 0. (default)
* - "domain-min": Fills toward the minimum value of the visible domain.
* - number: Fills toward a specific numeric reference (e.g. 100). Automatic domain includes the baseline.
*/
baseline?: number | "zero" | "domain-min"
/** Primary series color applied to stroke, active marker, and gradient stops. (default: "var(--chart-1)") */
color?: string
/** Active selection / crosshair highlight color. (default: "var(--chart-selection)") */
selectionColor?: string
/**
* Gradient depth mode:
* - "surface": Controlled semantic fade from series identity toward chart surface (default)
* - "none": Flat semantic area fill
*/
gradientMode?: GradientDepthMode
/**
* Maximum/leading fill opacity at the signal boundary. (default: 0.32)
* Subordinate gradient stops derive proportionally.
*/
fillOpacity?: number
/** Whether to render subtle horizontal grid reference lines. (default: true) */
showGrid?: boolean
/** Whether to render horizontal domain tick labels. (default: true) */
showXAxis?: boolean
/** Whether to render vertical metric scale ticks. (default: true) */
showYAxis?: boolean
/** Whether to render series identity legend. (default: false) */
showLegend?: boolean
/** Enables persistent tooltip pinning on click, tap, or Enter/Space. (default: true) */
lockableTooltip?: boolean
/** Missing value policy: "gap" (default) or "connect". */
missingValuePolicy?: "gap" | "connect"
/** Motion configuration: true for default animation, false to disable, or object with duration. (default: true) */
motion?: boolean | { duration?: number }
/** Optional custom value formatter for tooltips and Y-axis */
valueFormatter?: (value: number) => string
/** Optional custom domain/x formatter for tooltips and X-axis */
xFormatter?: (value: string | number) => string
/** Accessible title for screen readers and container. */
title?: string
/** Accessible description for screen readers and container. */
description?: string
/** Loading state indicator. */
loading?: boolean
/** Error state or error message. */
error?: Error | string | null
/** Metric unavailable notice. */
unavailable?: boolean | string
/** Callback fired when retry button is pressed in error state. */
onRetry?: () => void
/** Additional CSS class names. */
className?: string
}
/* -------------------------------------------------------------------------- */
/* Pure Math, Domain & Data Sanitization */
/* -------------------------------------------------------------------------- */
export function isFiniteNumber(val: unknown): val is number {
return typeof val === "number" && Number.isFinite(val)
}
/**
* Calculates a truthful Y domain considering series observations and baseline reference.
*/
export function calculateGradientDepthDomain(
data: readonly Record<string, unknown>[],
seriesKey: string,
baseline: number | "zero" | "domain-min" = "zero",
explicitDomain?: [number, number] | "auto"
): [number, number] {
if (explicitDomain && explicitDomain !== "auto") {
return explicitDomain
}
const values: number[] = []
for (const row of data) {
const val = row[seriesKey]
if (isFiniteNumber(val)) {
values.push(val)
}
}
if (values.length === 0) {
return [0, 100]
}
let min = Math.min(...values)
let max = Math.max(...values)
// Enforce baseline in domain
if (baseline === "zero") {
min = Math.min(min, 0)
max = Math.max(max, 0)
} else if (typeof baseline === "number" && Number.isFinite(baseline)) {
min = Math.min(min, baseline)
max = Math.max(max, baseline)
}
// Handle single-value scale safely
if (min === max) {
if (min === 0) return [-1, 1]
if (min > 0) return [0, Math.ceil(min * 1.25)]
return [Math.floor(min * 1.25), 0]
}
const span = max - min
const pad = span * 0.08
const safeMin = min >= 0 && baseline === "zero" ? 0 : Math.floor(min - pad)
const safeMax = Math.ceil(max + pad)
return [safeMin, safeMax]
}
/**
* Calculates deterministic gradient stop opacities based on fillOpacity and gradientMode.
*/
export function calculateGradientStops(
fillOpacity: number = 0.32,
gradientMode: GradientDepthMode = "surface"
): { clampedOpacity: number; middleOpacity: number; bottomOpacity: number } {
const rawOpacity = typeof fillOpacity === "number" && Number.isFinite(fillOpacity) ? fillOpacity : 0.32
const clamped = Math.max(0, Math.min(1, rawOpacity))
if (gradientMode === "none") {
return {
clampedOpacity: clamped,
middleOpacity: clamped,
bottomOpacity: clamped,
}
}
return {
clampedOpacity: clamped,
middleOpacity: Number((clamped * 0.42).toFixed(3)),
bottomOpacity: Math.min(0.02, Number((clamped * 0.05).toFixed(3))),
}
}
/**
* Normalizes dataset into sanitized records without mutating caller objects.
* Missing/non-finite observations are converted to null for truthful gap rendering.
*/
export function normalizeGradientDepthData<TData extends Record<string, unknown>>(
data: readonly TData[],
xKey: string,
seriesKey: string
): Record<string, unknown>[] {
return data.map((item, idx) => {
const rawVal = item[seriesKey]
const xVal = item[xKey] ?? `Point ${idx + 1}`
return {
...item,
[xKey]: xVal,
[seriesKey]: isFiniteNumber(rawVal) ? rawVal : null,
}
})
}
/* -------------------------------------------------------------------------- */
/* Synchronized Tooltip Component */
/* -------------------------------------------------------------------------- */
interface GradientDepthAreaTooltipContentProps {
seriesKey: string
seriesLabel: string
color: string
valueFormatter: (value: number) => string
isCompact?: boolean
isLocked?: boolean
onUnlock?: () => void
activeDatum?: {
x: string | number
value: number | null
} | null
payload?: any[]
label?: string | number
}
function GradientDepthAreaTooltipContent({
seriesKey,
seriesLabel,
color,
valueFormatter,
isCompact = false,
isLocked = false,
onUnlock,
activeDatum,
payload,
label,
}: GradientDepthAreaTooltipContentProps) {
const currentDatum = React.useMemo(() => {
if (activeDatum) return activeDatum
if (payload && payload.length > 0) {
const p = payload[0]
const row = p.payload || {}
return {
x: label ?? row.x ?? "",
value: isFiniteNumber(row[seriesKey]) ? row[seriesKey] : null,
}
}
return null
}, [activeDatum, payload, label, seriesKey])
if (!currentDatum) return null
const hasValue = isFiniteNumber(currentDatum.value)
return (
<div
className={cn(
"rounded-lg border border-white/10 bg-zinc-950/95 p-2.5 shadow-xl backdrop-blur-md transition-all duration-100",
isCompact ? "min-w-[130px] p-2 text-[10px]" : "min-w-[160px] text-xs"
)}
style={{
boxShadow: "0 8px 24px -4px rgba(0, 0, 0, 0.5), 0 2px 6px -1px rgba(0, 0, 0, 0.3)",
}}
>
{/* Header with Coordinate and Lock status */}
<div className="flex items-center justify-between gap-2 border-b border-white/10 pb-1.5 mb-2 font-mono">
<span className="font-semibold text-zinc-300 truncate">
{String(currentDatum.x)}
</span>
{isLocked && (
<button
type="button"
onClick={(e) => {
e.stopPropagation()
onUnlock?.()
}}
className="flex items-center gap-1 rounded bg-zinc-800/80 px-1 py-0.5 text-[9px] text-zinc-400 hover:text-white transition-colors"
title="Press Escape or click to unlock inspection"
>
<HugeiconsIcon icon={LockKeyIcon} size={10} className="text-amber-400" />
<span>PINNED</span>
</button>
)}
</div>
{/* Series Row */}
<div className="flex items-center justify-between gap-3 font-mono">
<div className="flex items-center gap-2 truncate">
<span
className="size-2 rounded-full shrink-0"
style={{ backgroundColor: color }}
/>
<span className="text-zinc-400 truncate">{seriesLabel}</span>
</div>
<span className={cn("font-bold shrink-0", hasValue ? "text-white" : "text-zinc-500 italic")}>
{hasValue ? valueFormatter(currentDatum.value!) : "Unavailable"}
</span>
</div>
</div>
)
}
/* -------------------------------------------------------------------------- */
/* Main Component: GradientDepthArea */
/* -------------------------------------------------------------------------- */
export function GradientDepthArea<
TData extends Record<string, unknown> = Record<string, unknown>,
XVal extends string | number = string | number
>({
data = [],
xKey,
series,
height = 320,
curve = "monotone",
domain = "auto",
baseline = "zero",
color = "var(--chart-1, #3b82f6)",
selectionColor = "var(--chart-selection, #38bdf8)",
gradientMode = "surface",
fillOpacity = 0.32,
showGrid = true,
showXAxis = true,
showYAxis = true,
showLegend = false,
lockableTooltip = true,
missingValuePolicy = "gap",
motion = true,
valueFormatter = (val: number) => val.toLocaleString(),
xFormatter,
title,
description,
loading = false,
error = null,
unavailable = false,
onRetry,
className,
}: GradientDepthAreaProps<TData, XVal>) {
const isReducedMotion = useChartReducedMotion()
const containerRef = React.useRef<HTMLDivElement>(null)
// State: locked observation index
const [lockedIndex, setLockedIndex] = React.useState<number | null>(null)
const [hoverIndex, setHoverIndex] = React.useState<number | null>(null)
// Unique deterministic SVG gradient ID (SSR and hydration safe)
const rawId = React.useId()
const gradientId = React.useMemo(() => {
return `plotcn-gradient-depth-${rawId.replace(/[^a-zA-Z0-9_-]/g, "")}`
}, [rawId])
// Normalized data & safe Y domain
const normalizedData = React.useMemo(() => {
if (!data || data.length === 0 || !series?.key) return []
return normalizeGradientDepthData(data, xKey, series.key)
}, [data, xKey, series?.key])
const safeDomain = React.useMemo(() => {
return calculateGradientDepthDomain(normalizedData, series?.key || "", baseline, domain)
}, [normalizedData, series?.key, baseline, domain])
// Compute Recharts baseValue
const rechartsBaseValue = React.useMemo(() => {
if (baseline === "domain-min") return "dataMin"
if (typeof baseline === "number" && Number.isFinite(baseline)) return baseline
return 0
}, [baseline])
// Resolve fill opacity stops
const { clampedOpacity, middleOpacity, bottomOpacity } = React.useMemo(() => {
return calculateGradientStops(fillOpacity, gradientMode)
}, [fillOpacity, gradientMode])
// Active observation data
const activeRowIndex = lockedIndex ?? hoverIndex
const activeRow = activeRowIndex !== null && normalizedData[activeRowIndex] ? normalizedData[activeRowIndex] : null
const activeDatum = React.useMemo(() => {
if (!activeRow) return null
const x = activeRow[xKey] as XVal
const val = activeRow[series.key]
return {
x,
value: isFiniteNumber(val) ? val : null,
}
}, [activeRow, xKey, series?.key])
// Container height measurements for compact mode
const isCompact = typeof height === "number" && height <= 260
// Keyboard navigation
const handleKeyDown = React.useCallback(
(e: React.KeyboardEvent<HTMLDivElement>) => {
if (!normalizedData || normalizedData.length === 0) return
if (e.key === "Escape") {
e.preventDefault()
setLockedIndex(null)
return
}
if (e.key === "Enter" || e.key === " ") {
if (!lockableTooltip) return
e.preventDefault()
if (lockedIndex !== null) {
setLockedIndex(null)
} else if (hoverIndex !== null) {
setLockedIndex(hoverIndex)
} else {
setLockedIndex(0)
}
return
}
const currentIndex = lockedIndex ?? hoverIndex ?? 0
let nextIndex = currentIndex
if (e.key === "ArrowLeft" || e.key === "ArrowDown") {
e.preventDefault()
nextIndex = Math.max(0, currentIndex - 1)
} else if (e.key === "ArrowRight" || e.key === "ArrowUp") {
e.preventDefault()
nextIndex = Math.min(normalizedData.length - 1, currentIndex + 1)
} else if (e.key === "Home") {
e.preventDefault()
nextIndex = 0
} else if (e.key === "End") {
e.preventDefault()
nextIndex = normalizedData.length - 1
} else {
return
}
if (lockedIndex !== null) {
setLockedIndex(nextIndex)
} else {
setHoverIndex(nextIndex)
}
},
[normalizedData, lockedIndex, hoverIndex, lockableTooltip]
)
// Chart mouse / touch handlers
const handleMouseMove = React.useCallback((state: any) => {
if (state && state.activeTooltipIndex !== undefined) {
setHoverIndex(state.activeTooltipIndex)
}
}, [])
const handleMouseLeave = React.useCallback(() => {
setHoverIndex(null)
}, [])
const handleChartClick = React.useCallback(
(state: any) => {
if (!lockableTooltip) return
if (state && state.activeTooltipIndex !== undefined) {
const idx = state.activeTooltipIndex
setLockedIndex((prev) => (prev === idx ? null : idx))
}
},
[lockableTooltip]
)
/* --- Truthful State Fallbacks --- */
if (loading) {
return (
<figure
role="region"
aria-label={title || "Gradient depth area loading"}
className={cn("plotcn-gradient-depth-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartLoadingState description="Loading gradient depth visualization..." />
</figure>
)
}
if (unavailable) {
return (
<figure
role="region"
aria-label={title || "Gradient depth area unavailable"}
className={cn("plotcn-gradient-depth-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartUnavailableState description={typeof unavailable === "string" ? unavailable : "Metric observations are currently unavailable."} />
</figure>
)
}
if (error) {
const errorDescription = error instanceof Error ? error.message : typeof error === "string" ? error : "An error occurred."
return (
<figure
role="region"
aria-label={title || "Gradient depth area error"}
className={cn("plotcn-gradient-depth-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartErrorState
title="Gradient Depth Area Configuration Error"
description={errorDescription}
onRetry={onRetry}
/>
</figure>
)
}
if (!normalizedData || normalizedData.length === 0) {
return (
<figure
role="region"
aria-label={title || "Gradient depth area empty"}
className={cn("plotcn-gradient-depth-area relative w-full overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4", className)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
<ChartEmptyState description="No observations available for gradient depth rendering." />
</figure>
)
}
const chartMargins = isCompact
? { top: 8, right: 10, left: -22, bottom: 0 }
: { top: 12, right: 16, left: -16, bottom: 0 }
return (
<figure
ref={containerRef}
role="region"
aria-label={title || `${series.label} gradient depth area chart`}
tabIndex={0}
onKeyDown={handleKeyDown}
className={cn(
"plotcn-gradient-depth-area group relative flex w-full flex-col overflow-hidden rounded-xl border border-white/10 bg-zinc-950 p-4 transition-all focus:outline-none focus-visible:ring-2 focus-visible:ring-[var(--chart-focus,#38bdf8)]",
className
)}
style={{ height, minHeight: typeof height === "number" ? height : 320 }}
>
{/* Screen Reader Live Announcements */}
<div className="sr-only" aria-live="polite">
{activeDatum
? `Selected observation ${String(activeDatum.x)}, ${series.label}: ${
isFiniteNumber(activeDatum.value) ? valueFormatter(activeDatum.value) : "Unavailable"
}${lockedIndex !== null ? " (Pinned)" : ""}`
: `${title || series.label}. Chart contains ${normalizedData.length} observations.`}
</div>
{/* Optional Header / Legend */}
{showLegend && (
<div className="mb-3 flex items-center justify-between border-b border-white/5 pb-2 text-xs font-mono">
<div className="flex items-center gap-2">
<span
className="h-2 w-4 rounded-xs shrink-0"
style={{
backgroundColor: color,
opacity: clampedOpacity,
borderTop: `2px solid ${color}`,
}}
/>
<span className="text-zinc-200 font-semibold">{series.label}</span>
</div>
<span className="text-[10px] text-zinc-500">
{gradientMode === "surface" ? "Semantic Surface Fade" : "Flat Fill"}
</span>
</div>
)}
{/* Main Chart Canvas */}
<div className="relative flex-1 w-full min-h-0">
<ChartContainer className="h-full w-full">
<ResponsiveContainer width="100%" height="100%" minWidth={0} minHeight={0}>
<AreaChart
data={normalizedData}
margin={chartMargins}
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
onClick={handleChartClick}
>
{/* SVG Semantic Linear Gradient Definition */}
<defs>
<linearGradient id={gradientId} x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stopColor={color} stopOpacity={clampedOpacity} />
<stop offset="55%" stopColor={color} stopOpacity={middleOpacity} />
<stop offset="100%" stopColor={color} stopOpacity={bottomOpacity} />
</linearGradient>
</defs>
{showGrid && (
<CartesianGrid
strokeDasharray="3 3"
stroke="var(--chart-grid, rgba(255,255,255,0.06))"
vertical={false}
strokeOpacity={0.7}
/>
)}
{showXAxis && (
<XAxis
dataKey={xKey as any}
stroke="var(--chart-axis, rgba(255,255,255,0.12))"
tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: isCompact ? 10 : 11 }}
tickLine={false}
axisLine={{ stroke: "var(--chart-axis-line, rgba(255,255,255,0.12))", strokeOpacity: 0.5 }}
tickFormatter={xFormatter}
/>
)}
{showYAxis && (
<YAxis
domain={safeDomain as any}
stroke="var(--chart-axis, rgba(255,255,255,0.12))"
tick={{ fill: "var(--chart-axis, #a1a1aa)", fontSize: isCompact ? 10 : 11 }}
tickLine={false}
axisLine={false}
tickFormatter={valueFormatter}
width={isCompact ? 40 : 50}
/>
)}
<Tooltip
isAnimationActive={false}
cursor={{
stroke: lockedIndex !== null ? selectionColor : "var(--chart-crosshair, rgba(255,255,255,0.28))",
strokeWidth: 1.5,
strokeDasharray: "4 4",
}}
content={
<GradientDepthAreaTooltipContent
seriesKey={series.key}
seriesLabel={series.label}
color={color}
valueFormatter={valueFormatter}
isCompact={isCompact}
isLocked={lockedIndex !== null}
onUnlock={() => setLockedIndex(null)}
/>
}
/>
{/* Locked crosshair reference line */}
{lockedIndex !== null && activeRow && (
<ReferenceLine
x={activeRow[xKey] as any}
stroke={selectionColor}
strokeWidth={1.5}
strokeDasharray="2 2"
/>
)}
{/* Single Quantitative Magnitude Area with Semantic Gradient Fill */}
<Area
type={curve}
dataKey={series.key as any}
name={series.label}
stroke={color}
strokeWidth={2}
fill={`url(#${gradientId})`}
fillOpacity={1}
baseValue={rechartsBaseValue}
connectNulls={missingValuePolicy === "connect"}
isAnimationActive={motion !== false && !isReducedMotion}
animationDuration={typeof motion === "object" && motion.duration ? motion.duration : 350}
dot={false}
activeDot={{
r: 5,
fill: color,
stroke: "var(--background, #09090b)",
strokeWidth: 2,
}}
/>
</AreaChart>
</ResponsiveContainer>
</ChartContainer>
</div>
{/* Structured HTML Data Alternative for Screen Readers */}
<div className="sr-only">
<table>
<caption>{title || `${series.label} gradient depth area observation data`}</caption>
<thead>
<tr>
<th scope="col">Coordinate ({xKey})</th>
<th scope="col">{series.label}</th>
</tr>
</thead>
<tbody>
{normalizedData.map((row, idx) => {
const xVal = String(row[xKey] ?? idx)
const numVal = row[series.key]
return (
<tr key={idx}>
<td>{xVal}</td>
<td>{isFiniteNumber(numVal) ? valueFormatter(numVal) : "Unavailable"}</td>
</tr>
)
})}
</tbody>
</table>
</div>
</figure>
)
}