shadimage
Bklit UI
componentcandlestick-chartsource: ok · hit

Candlestick Chart

A composable OHLC candlestick chart with gradients, patterns, tooltips, and hover interactions

npx shadcn add "https://ui.bklit.com/r/candlestick-chart.json"
free

Visual style

gradient

Interaction

tooltip

Tags auto-generated by rules on 9/24/2026 — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationmotion

Registry dependencies

@bklit/chart-context@bklit/chart-animation@bklit/grid@bklit/x-axis@bklit/y-axis@bklit/chart-tooltip@bklit/utils

Files4 · source cached 10/9/2026, 6:47:00 PM

src/charts/candlestick-chart.tsx
"use client";

import { ParentSize } from "@visx/responsive";
import { scaleLinear, scaleTime } from "@visx/scale";
import { bisector } from "d3-array";
import type { Transition } from "motion/react";
import {
  Children,
  isValidElement,
  memo,
  type ReactElement,
  type ReactNode,
  useCallback,
  useEffect,
  useMemo,
  useRef,
  useState,
} from "react";
import { cn } from "@/lib/utils";
import {
  isClipExcludedComponent,
  isPostOverlayComponent,
  isUnderlayComponent,
} from "./chart-child-passthrough";
import { ChartProvider, type LineConfig, type Margin } from "./chart-context";
import { shortDateFmt } from "./chart-formatters";
import { DEFAULT_CHART_LIFECYCLE } from "./chart-phase";
import {
  decimateOhlcData,
  maxRenderPointsForWidth,
} from "./decimate-time-series";
import { extractReferenceAreaConfigs } from "./reference-area-config";
import { useChartInteraction } from "./use-chart-interaction";
import { wrapSingleYScale } from "./y-axis-scales";

export interface OHLCDataPoint {
  date: Date;
  open: number;
  high: number;
  low: number;
  close: number;
}

export interface CandlestickChartProps {
  /** OHLC data array */
  data: OHLCDataPoint[];
  /** Key in data for the x-axis (date). Default: "date" */
  xDataKey?: string;
  /** Chart margins */
  margin?: Partial<Margin>;
  /** Animation duration in milliseconds. Default: 1500 */
  animationDuration?: number;
  /** 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;
  /** Inline styles for the container (e.g. { height: 320 }) */
  style?: React.CSSProperties;
  /** Gap between candles as fraction of slot width (0–1). Default: 0.2. Ignored when candleWidth is set. */
  candleGap?: number;
  /** Fixed candle body width in pixels. If set, overrides candleGap. */
  candleWidth?: number;
  /** When set, xScale uses this domain instead of deriving from data. Use with brush so main chart and strip share the same scale. */
  xDomain?: [Date, Date];
  /** When xDomain is set, use this as the number of slots for scale padding (e.g. full data length). */
  xDomainSlotCount?: number;
  /** Child components (Candlestick, Grid, XAxis, YAxis, ChartTooltip, etc.) */
  children: ReactNode;
}

const DEFAULT_MARGIN: Margin = { top: 40, right: 40, bottom: 40, left: 40 };

interface ChartInnerProps {
  width: number;
  height: number;
  data: Record<string, unknown>[];
  xDataKey: string;
  margin: Margin;
  animationDuration: number;
  enterTransition?: Transition;
  revealSignature?: string;
  candleGap: number;
  candleWidthProp?: number;
  xDomain?: [Date, Date];
  xDomainSlotCount?: number;
  children: ReactNode;
  containerRef: React.RefObject<HTMLDivElement | null>;
}

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,
  enterTransition,
  revealSignature = "",
  candleGap,
  candleWidthProp,
  xDomain,
  xDomainSlotCount,
  children,
  containerRef,
}: ChartInnerProps) {
  const [isLoaded, setIsLoaded] = useState(false);
  const [revealEpoch, setRevealEpoch] = useState(0);

  const innerWidth = width - margin.left - margin.right;
  const innerHeight = height - margin.top - margin.bottom;

  const xAccessor = useCallback(
    (d: Record<string, unknown>): Date => {
      const value = d[xDataKey];
      return value instanceof Date ? value : new Date(value as string | number);
    },
    [xDataKey]
  );

  const bisectDate = useMemo(
    () => bisector<Record<string, unknown>, Date>((d) => xAccessor(d)).left,
    [xAccessor]
  );

  const slotCount =
    xDomain && xDomainSlotCount != null ? xDomainSlotCount : data.length;
  const slotWidth = innerWidth / Math.max(slotCount, 1);
  const xScale = useMemo(() => {
    const minTime = xDomain
      ? xDomain[0].getTime()
      : Math.min(...data.map((d) => xAccessor(d).getTime()));
    const maxTime = xDomain
      ? xDomain[1].getTime()
      : Math.max(...data.map((d) => xAccessor(d).getTime()));
    const padding = slotWidth / 2;
    return scaleTime({
      range: [padding, innerWidth - padding],
      domain: [minTime, maxTime],
    });
  }, [innerWidth, data, xAccessor, slotWidth, xDomain]);

  const yScale = useMemo(() => {
    let minVal = Number.POSITIVE_INFINITY;
    let maxVal = Number.NEGATIVE_INFINITY;
    for (const d of data) {
      const low = d.low as number | undefined;
      const high = d.high as number | undefined;
      if (typeof low === "number" && low < minVal) {
        minVal = low;
      }
      if (typeof high === "number" && high > maxVal) {
        maxVal = high;
      }
    }
    if (minVal === Number.POSITIVE_INFINITY) {
      minVal = 0;
    }
    if (maxVal === Number.NEGATIVE_INFINITY) {
      maxVal = 100;
    }
    const padding = (maxVal - minVal) * 0.05 || 1;
    return scaleLinear({
      range: [innerHeight, 0],
      domain: [minVal - padding, maxVal + padding],
      nice: true,
    });
  }, [innerHeight, data]);

  const columnWidth = slotWidth;
  const bandWidth = candleWidthProp ?? slotWidth * (1 - candleGap);

  const lines: LineConfig[] = useMemo(
    () => [
      { dataKey: "close", stroke: "var(--chart-line-primary)", strokeWidth: 0 },
    ],
    []
  );

  const renderData = useMemo(
    () => decimateOhlcData(data, maxRenderPointsForWidth(innerWidth)),
    [data, innerWidth]
  );

  const dateLabels = useMemo(
    () => data.map((d) => shortDateFmt.format(xAccessor(d))),
    [data, xAccessor]
  );

  // biome-ignore lint/correctness/useExhaustiveDependencies: revealSignature
  useEffect(() => {
    setRevealEpoch((n) => n + 1);
    setIsLoaded(false);
    const timer = setTimeout(() => setIsLoaded(true), animationDuration);
    return () => clearTimeout(timer);
  }, [animationDuration, revealSignature]);

  const {
    tooltipData,
    setTooltipData,
    selection,
    clearSelection,
    interactionHandlers,
    interactionStyle,
  } = useChartInteraction({
    xScale,
    yScale,
    yScales: wrapSingleYScale(yScale),
    data,
    lines,
    margin,
    xAccessor,
    bisectDate,
    canInteract: isLoaded,
  });

  const hoveredCandleIndex = tooltipData?.index ?? null;

  const isDefsComponent = (child: ReactElement): boolean => {
    const displayName =
      (child.type as { displayName?: string })?.displayName ||
      (child.type as { name?: string })?.name ||
      "";
    return (
      displayName.includes("Gradient") ||
      displayName.includes("Pattern") ||
      displayName === "LinearGradient" ||
      displayName === "RadialGradient" ||
      displayName === "Lines" ||
      displayName === "PatternLines"
    );
  };

  const defsChildren: ReactElement[] = [];
  const clipExcludedChildren: ReactElement[] = [];
  const underlayChildren: ReactElement[] = [];
  const preOverlayChildren: ReactElement[] = [];
  const postOverlayChildren: ReactElement[] = [];
  Children.forEach(children, (child) => {
    if (!isValidElement(child)) {
      return;
    }
    if (isDefsComponent(child)) {
      defsChildren.push(child);
    } else if (isPostOverlayComponent(child)) {
      postOverlayChildren.push(child);
    } else if (isClipExcludedComponent(child)) {
      clipExcludedChildren.push(child);
    } else if (isUnderlayComponent(child)) {
      underlayChildren.push(child);
    } else {
      preOverlayChildren.push(child);
    }
  });

  const referenceAreas = useMemo(
    () => extractReferenceAreaConfigs(children),
    [children]
  );

  const yScales = useMemo(() => wrapSingleYScale(yScale), [yScale]);

  const contextValue = useMemo(
    () => ({
      ...DEFAULT_CHART_LIFECYCLE,
      data,
      renderData,
      xScale,
      yScale,
      yScales,
      width,
      height,
      innerWidth,
      innerHeight,
      margin,
      columnWidth,
      tooltipData,
      setTooltipData,
      containerRef,
      lines,
      referenceAreas,
      isLoaded,
      animationDuration,
      enterTransition,
      revealEpoch,
      xAccessor,
      dateLabels,
      selection: selection ?? null,
      clearSelection,
      bandWidth,
      hoveredCandleIndex,
    }),
    [
      data,
      renderData,
      xScale,
      yScale,
      yScales,
      width,
      height,
      innerWidth,
      innerHeight,
      margin,
      columnWidth,
      tooltipData,
      setTooltipData,
      containerRef,
      lines,
      referenceAreas,
      isLoaded,
      animationDuration,
      enterTransition,
      revealEpoch,
      xAccessor,
      dateLabels,
      selection,
      clearSelection,
      bandWidth,
      hoveredCandleIndex,
    ]
  );

  return (
    <ChartProvider value={contextValue}>
      <svg aria-hidden="true" height={height} width={width}>
        <defs>
          {/* Default vertical gradients for positive/negative candles (emerald / red) */}
          <linearGradient id="candlestick-positive" x1="0" x2="0" y1="1" y2="0">
            <stop offset="0%" stopColor="var(--color-emerald-500)" />
            <stop offset="100%" stopColor="var(--color-emerald-500)" />
          </linearGradient>
          <linearGradient id="candlestick-negative" x1="0" x2="0" y1="1" y2="0">
            <stop offset="0%" stopColor="var(--color-red-500)" />
            <stop offset="100%" stopColor="var(--color-red-500)" />
          </linearGradient>
          {defsChildren}
        </defs>
        <rect fill="transparent" height={height} width={width} x={0} y={0} />
        <g
          {...interactionHandlers}
          style={interactionStyle}
          transform={`translate(${margin.left},${margin.top})`}
        >
          <rect
            fill="transparent"
            height={innerHeight}
            width={innerWidth}
            x={0}
            y={0}
          />
          {clipExcludedChildren}
          {underlayChildren}
          {preOverlayChildren}
          {postOverlayChildren}
        </g>
      </svg>
    </ChartProvider>
  );
});

export function CandlestickChart({
  data,
  xDataKey = "date",
  margin: marginProp,
  animationDuration = 1100,
  enterTransition,
  revealSignature,
  aspectRatio = "2 / 1",
  className = "",
  style,
  candleGap = 0.2,
  candleWidth,
  xDomain,
  xDomainSlotCount,
  children,
}: CandlestickChartProps) {
  const containerRef = useRef<HTMLDivElement>(null);
  const margin = { ...DEFAULT_MARGIN, ...marginProp };
  const dataAsRecords = data as unknown as Record<string, unknown>[];

  return (
    <div
      className={cn("relative w-full", className)}
      ref={containerRef}
      style={{ aspectRatio, touchAction: "none", ...style }}
    >
      <ParentSize debounceTime={10}>
        {({ width, height }) => (
          <ChartInner
            animationDuration={animationDuration}
            candleGap={candleGap}
            candleWidthProp={candleWidth}
            containerRef={containerRef}
            data={dataAsRecords}
            enterTransition={enterTransition}
            height={height}
            margin={margin}
            revealSignature={revealSignature}
            width={width}
            xDataKey={xDataKey}
            xDomain={xDomain}
            xDomainSlotCount={xDomainSlotCount}
          >
            {children}
          </ChartInner>
        )}
      </ParentSize>
    </div>
  );
}

CandlestickChart.displayName = "CandlestickChart";

export default CandlestickChart;
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/candlestick.tsx
"use client";

import type { Transition } from "motion/react";
import { motion } from "motion/react";
import { memo, useMemo } from "react";
import { useChart } from "./chart-context";
import { useChartLegendHover } from "./chart-legend-hover";
import { transitionWithDelay } from "./motion-utils";

const DEFAULT_POSITIVE = "url(#candlestick-positive)";
const DEFAULT_NEGATIVE = "url(#candlestick-negative)";

const SOLID_POSITIVE = "var(--color-emerald-500)";
const SOLID_NEGATIVE = "var(--color-red-500)";
const WICK_WIDTH = 1.5;

export interface CandlestickProps {
  /** Whether to animate the candlesticks. Default: true */
  animate?: boolean;
  /** Fill for positive (close >= open) candles. Color or url(#gradient). Default: emerald */
  positiveFill?: string;
  /** Fill for negative candles. Color or url(#gradient). Default: red */
  negativeFill?: string;
  /** Optional pattern URL for body only (e.g. url(#pattern)). When set, body is drawn solid first, then pattern overlaid and masked to the body rect. */
  bodyPatternPositive?: string;
  /** Optional pattern URL for negative candle body. */
  bodyPatternNegative?: string;
  /** Inner border width on the body (drawn inside so it does not expand the shape). Default: 0 (off). */
  insideStrokeWidth?: number;
  /** Opacity when another candle is hovered. Default: 0.3 */
  fadedOpacity?: number;
  /** Dim non-hovered candles on hover. Default: true */
  showHoverFade?: boolean;
}

interface CandleGeometry {
  time: number;
  centerX: number;
  bodyTop: number;
  bodyHeight: number;
  bodyLeft: number;
  candleWidth: number;
  wickTop: number;
  wickHeight: number;
  wickLeft: number;
  bodySolidFill: string;
  wickFill: string;
  bodyPattern?: string;
  insideStrokeWidth: number;
  isPositive: boolean;
}

function getSolidColor(isPositive: boolean): string {
  return isPositive ? SOLID_POSITIVE : SOLID_NEGATIVE;
}

function computeGeometries(
  renderData: Record<string, unknown>[],
  xScale: (value: Date) => number | undefined,
  yScale: (value: number) => number | undefined,
  xAccessor: (d: Record<string, unknown>) => Date,
  candleWidth: number,
  positiveFill: string,
  negativeFill: string,
  bodyPatternPositive: string | undefined,
  bodyPatternNegative: string | undefined,
  insideStrokeWidth: number
): CandleGeometry[] {
  return renderData.map((d) => {
    const date = xAccessor(d);
    const open = d.open as number;
    const high = d.high as number;
    const low = d.low as number;
    const close = d.close as number;
    const centerX = xScale(date) ?? 0;
    const yHigh = yScale(high) ?? 0;
    const yLow = yScale(low) ?? 0;
    const yOpen = yScale(open) ?? 0;
    const yClose = yScale(close) ?? 0;
    const bodyTop = Math.min(yOpen, yClose);
    const bodyHeight = Math.abs(yClose - yOpen) || 1;
    const bodyLeft = centerX - candleWidth / 2;
    const wickTop = Math.min(yHigh, yLow);
    const wickHeight = Math.abs(yLow - yHigh) || 1;
    const isPositive = close >= open;
    const fill = isPositive ? positiveFill : negativeFill;
    const bodyPattern = isPositive ? bodyPatternPositive : bodyPatternNegative;
    const hasPatternOverlay = Boolean(bodyPattern);
    const bodySolidFill = hasPatternOverlay ? getSolidColor(isPositive) : fill;

    return {
      time: date.getTime(),
      centerX,
      bodyTop,
      bodyHeight,
      bodyLeft,
      candleWidth,
      wickTop,
      wickHeight,
      wickLeft: centerX - WICK_WIDTH / 2,
      bodySolidFill,
      wickFill: hasPatternOverlay ? bodySolidFill : fill,
      bodyPattern: hasPatternOverlay ? bodyPattern : undefined,
      insideStrokeWidth,
      isPositive,
    };
  });
}

function geometryDimOpacity(
  geometry: CandleGeometry,
  fadedOpacity: number,
  legendHoveredIndex: number | null,
  hoveredTime: number | null
): number {
  if (legendHoveredIndex !== null) {
    const dimFromLegend =
      (legendHoveredIndex === 0 && !geometry.isPositive) ||
      (legendHoveredIndex === 1 && geometry.isPositive);
    return dimFromLegend ? fadedOpacity : 1;
  }
  if (hoveredTime !== null && geometry.time !== hoveredTime) {
    return fadedOpacity;
  }
  return 1;
}

const CandlestickBody = memo(function CandlestickBody({
  geometry,
}: {
  geometry: CandleGeometry;
}) {
  const {
    wickLeft,
    wickTop,
    wickHeight,
    wickFill,
    bodyLeft,
    bodyTop,
    bodyHeight,
    candleWidth,
    bodySolidFill,
    bodyPattern,
    insideStrokeWidth,
  } = geometry;

  return (
    <>
      <rect
        fill={wickFill}
        height={wickHeight}
        width={WICK_WIDTH}
        x={wickLeft}
        y={wickTop}
      />
      <rect
        fill={bodySolidFill}
        height={bodyHeight}
        rx={1}
        ry={1}
        stroke={bodySolidFill}
        strokeWidth={1}
        width={candleWidth}
        x={bodyLeft}
        y={bodyTop}
      />
      {bodyPattern ? (
        <rect
          fill={bodyPattern}
          height={bodyHeight}
          rx={1}
          ry={1}
          width={candleWidth}
          x={bodyLeft}
          y={bodyTop}
        />
      ) : null}
      {insideStrokeWidth > 0 ? (
        <rect
          fill="none"
          height={bodyHeight - insideStrokeWidth}
          rx={1}
          ry={1}
          stroke={bodySolidFill}
          strokeWidth={insideStrokeWidth}
          width={candleWidth - insideStrokeWidth}
          x={bodyLeft + insideStrokeWidth / 2}
          y={bodyTop + insideStrokeWidth / 2}
        />
      ) : null}
    </>
  );
});

const CandlestickBodies = memo(function CandlestickBodies({
  geometries,
  fadedOpacity,
  legendHoveredIndex,
  hoveredTime,
}: {
  geometries: CandleGeometry[];
  fadedOpacity: number;
  legendHoveredIndex: number | null;
  hoveredTime: number | null;
}) {
  return (
    <>
      {geometries.map((geometry) => (
        <g
          key={geometry.time}
          opacity={geometryDimOpacity(
            geometry,
            fadedOpacity,
            legendHoveredIndex,
            hoveredTime
          )}
          style={{ transition: "opacity 0.15s ease-in-out" }}
        >
          <CandlestickBody geometry={geometry} />
        </g>
      ))}
    </>
  );
});

interface AnimatedCandleProps {
  geometry: CandleGeometry;
  delay: number;
  enterTransition: Transition;
  revealEpoch: number;
}

function AnimatedCandle({
  geometry,
  delay,
  enterTransition,
  revealEpoch,
}: AnimatedCandleProps) {
  const t = transitionWithDelay(enterTransition, delay);
  const bodyOrigin = `${geometry.centerX}px ${geometry.bodyTop + geometry.bodyHeight / 2}px`;
  const wickCenterY = geometry.wickTop + geometry.wickHeight / 2;

  return (
    <motion.g
      animate={{ opacity: 1 }}
      initial={{ opacity: 0 }}
      key={`candle-enter-${geometry.time}-${revealEpoch}`}
      style={{ transformOrigin: `${geometry.centerX}px ${wickCenterY}px` }}
      transition={{ ...t, opacity: { duration: 0.15 } }}
    >
      <motion.rect
        animate={{ scaleY: 1 }}
        fill={geometry.wickFill}
        height={geometry.wickHeight}
        initial={{ scaleY: 0 }}
        style={{ transformOrigin: `${geometry.centerX}px ${wickCenterY}px` }}
        transition={t}
        width={WICK_WIDTH}
        x={geometry.wickLeft}
        y={geometry.wickTop}
      />
      <motion.rect
        animate={{ scaleY: 1 }}
        fill={geometry.bodySolidFill}
        height={geometry.bodyHeight}
        initial={{ scaleY: 0 }}
        rx={1}
        ry={1}
        stroke={geometry.bodySolidFill}
        strokeWidth={1}
        style={{ transformOrigin: bodyOrigin }}
        transition={t}
        width={geometry.candleWidth}
        x={geometry.bodyLeft}
        y={geometry.bodyTop}
      />
      {geometry.bodyPattern ? (
        <motion.rect
          animate={{ scaleY: 1 }}
          fill={geometry.bodyPattern}
          height={geometry.bodyHeight}
          initial={{ scaleY: 0 }}
          rx={1}
          ry={1}
          style={{ transformOrigin: bodyOrigin }}
          transition={t}
          width={geometry.candleWidth}
          x={geometry.bodyLeft}
          y={geometry.bodyTop}
        />
      ) : null}
    </motion.g>
  );
}

export function Candlestick({
  animate = true,
  positiveFill = DEFAULT_POSITIVE,
  negativeFill = DEFAULT_NEGATIVE,
  bodyPatternPositive,
  bodyPatternNegative,
  insideStrokeWidth = 0,
  fadedOpacity = 0.3,
  showHoverFade = true,
}: CandlestickProps) {
  const {
    data,
    xScale,
    yScale,
    xAccessor,
    animationDuration,
    enterTransition,
    revealEpoch = 0,
    isLoaded,
    bandWidth,
    columnWidth,
    hoveredCandleIndex,
  } = useChart();
  const { hoveredIndex: legendHoveredIndex } = useChartLegendHover();

  const candleWidth = Math.min(bandWidth ?? columnWidth * 0.8, columnWidth);

  const geometries = useMemo(
    () =>
      computeGeometries(
        data,
        xScale,
        yScale,
        xAccessor,
        candleWidth,
        positiveFill,
        negativeFill,
        bodyPatternPositive,
        bodyPatternNegative,
        insideStrokeWidth
      ),
    [
      data,
      xScale,
      yScale,
      xAccessor,
      candleWidth,
      positiveFill,
      negativeFill,
      bodyPatternPositive,
      bodyPatternNegative,
      insideStrokeWidth,
    ]
  );

  const hoveredTime = useMemo(() => {
    if (hoveredCandleIndex == null) {
      return null;
    }
    const point = data[hoveredCandleIndex];
    return point ? xAccessor(point).getTime() : null;
  }, [hoveredCandleIndex, data, xAccessor]);

  const highlightGeometry = useMemo(() => {
    if (hoveredCandleIndex == null) {
      return null;
    }
    const point = data[hoveredCandleIndex];
    if (!point) {
      return null;
    }
    return (
      computeGeometries(
        [point],
        xScale,
        yScale,
        xAccessor,
        candleWidth,
        positiveFill,
        negativeFill,
        bodyPatternPositive,
        bodyPatternNegative,
        insideStrokeWidth
      )[0] ?? null
    );
  }, [
    hoveredCandleIndex,
    data,
    xScale,
    yScale,
    xAccessor,
    candleWidth,
    positiveFill,
    negativeFill,
    bodyPatternPositive,
    bodyPatternNegative,
    insideStrokeWidth,
  ]);

  const defaultEnter: Transition = {
    type: "spring",
    duration: 0.8,
    bounce: 0.15,
  };
  const enter = enterTransition ?? defaultEnter;
  const staggerDelayMs =
    data.length > 0 ? (animationDuration * 0.6) / data.length : 0;

  if (animate && !isLoaded) {
    return (
      <g className="chart-candlesticks">
        {geometries.map((geometry, index) => (
          <AnimatedCandle
            delay={(index * staggerDelayMs) / 1000}
            enterTransition={enter}
            geometry={geometry}
            key={geometry.time}
            revealEpoch={revealEpoch}
          />
        ))}
      </g>
    );
  }

  return (
    <g className="chart-candlesticks">
      <CandlestickBodies
        fadedOpacity={fadedOpacity}
        geometries={geometries}
        hoveredTime={showHoverFade ? hoveredTime : null}
        legendHoveredIndex={legendHoveredIndex}
      />
      {highlightGeometry ? (
        <g>
          <CandlestickBody geometry={highlightGeometry} />
        </g>
      ) : null}
    </g>
  );
}

Candlestick.displayName = "Candlestick";

export default Candlestick;

More from Bklit UI

Browse all components in this library →