# Area Chart Interactive

A stock price chart with time-range toggles, a scrubbing cursor that updates the header, and animated transitions between ranges.

Area Chart Interactive is an open-source chart from suiss/ui for React, React Native, SwiftUI and Jetpack Compose. The @esuiss/ui CLI copies its source into your project, so you own the code.

Source: https://ui.suiss.app/docs/charts/area-chart-interactive

**React**

**Example**

```tsx title="components/charts/area-chart-interactive.tsx"
"use client"

import * as React from "react"
import {
  Area,
  AreaChart,
  CartesianGrid,
  ReferenceLine,
  useActiveTooltipDataPoints,
  XAxis,
  YAxis,
} from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
} from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Range = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y"

type Point = { index: number; price: number; label: string; tick: string }

const RANGES: Record<
  Range,
  {
    points: number
    seed: number
    volatility: number
    drift: number
    caption: string
  }
> = {
  "1D": {
    points: 78,
    seed: 11,
    volatility: 0.0025,
    drift: 0.46,
    caption: "Today",
  },
  "1W": {
    points: 35,
    seed: 23,
    volatility: 0.004,
    drift: 0.46,
    caption: "Past week",
  },
  "1M": {
    points: 22,
    seed: 37,
    volatility: 0.012,
    drift: 0.53,
    caption: "Past month",
  },
  "3M": {
    points: 64,
    seed: 41,
    volatility: 0.014,
    drift: 0.46,
    caption: "Past 3 months",
  },
  "1Y": {
    points: 52,
    seed: 59,
    volatility: 0.03,
    drift: 0.46,
    caption: "Past year",
  },
  "5Y": {
    points: 60,
    seed: 73,
    volatility: 0.045,
    drift: 0.46,
    caption: "Past 5 years",
  },
}

const LAST_PRICE = 254.32
const END = new Date(2026, 9, 6)
const SAMPLES = 80
const MORPH_DURATION = 520

const priceFormat = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
})
const changeFormat = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  signDisplay: "always",
})
const dayFormat = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
})
const weekdayFormat = new Intl.DateTimeFormat("en-US", { weekday: "short" })
const monthFormat = new Intl.DateTimeFormat("en-US", { month: "short" })
const monthYearFormat = new Intl.DateTimeFormat("en-US", {
  month: "long",
  year: "numeric",
})

function businessDays(count: number) {
  const days: Date[] = []
  const day = new Date(END)
  while (days.length < count) {
    if (day.getDay() % 6) days.unshift(new Date(day))
    day.setDate(day.getDate() - 1)
  }
  return days
}

function generate(range: Range): Point[] {
  const { points, seed, volatility, drift } = RANGES[range]
  let state = seed
  let value = 100
  const values = Array.from({ length: points }, () => {
    state = (state * 16807) % 2147483647
    value *= 1 + ((state - 1) / 2147483646 - drift) * volatility
    return value
  })
  const last = values.at(-1)
  if (last === undefined) return []
  const scale = LAST_PRICE / last
  const days = businessDays(points)
  const week = businessDays(5)

  return values.map((raw, index) => {
    const price = Number((raw * scale).toFixed(2))
    if (range === "1D") {
      const minutes = 570 + index * 5
      const hour = Math.floor(minutes / 60)
      const minute = minutes % 60
      const time = `${hour}:${String(minute).padStart(2, "0")}`
      return {
        index,
        price,
        label: time,
        tick: minute === 0 ? `${hour}:00` : "",
      }
    }
    if (range === "1W") {
      const day = week[Math.floor(index / 7)]
      const weekday = weekdayFormat.format(day)
      return {
        index,
        price,
        label: `${weekday} ${10 + (index % 7)}:00`,
        tick: index % 7 === 0 ? weekday : "",
      }
    }
    if (range === "1M" || range === "3M") {
      const label = dayFormat.format(days[index])
      return { index, price, label, tick: label }
    }
    if (range === "1Y") {
      const day = new Date(END)
      day.setDate(day.getDate() - (points - 1 - index) * 7)
      return {
        index,
        price,
        label: `${dayFormat.format(day)}, ${day.getFullYear()}`,
        tick: monthFormat.format(day),
      }
    }
    const month = new Date(
      END.getFullYear(),
      END.getMonth() - (points - 1 - index),
      1
    )
    return {
      index,
      price,
      label: monthYearFormat.format(month),
      tick: String(month.getFullYear()),
    }
  })
}

function resample(source: Point[]) {
  const samples: Point[] = []
  let previous = -1
  for (let index = 0; index < SAMPLES; index++) {
    const position = (index * (source.length - 1)) / (SAMPLES - 1)
    const from = Math.floor(position)
    const to = Math.min(source.length - 1, from + 1)
    const nearest = Math.round(position)
    const start = source[from]
    const end = source[to]
    const closest = source[nearest]
    if (!start || !end || !closest) return samples
    const price = start.price + (end.price - start.price) * (position - from)
    samples.push({
      index,
      price: Number(price.toFixed(2)),
      label: closest.label,
      tick: nearest !== previous ? closest.tick : "",
    })
    previous = nearest
  }
  return samples
}

function tickIndexes(points: Point[], max: number) {
  const ticks = points
    .filter(
      (point, index) => point.tick && point.tick !== points[index - 1]?.tick
    )
    .map((point) => point.index)
  if (ticks.length <= max) return ticks
  const step = Math.ceil(ticks.length / max)
  return ticks.filter((_, index) => index % step === 0)
}

function useMorph(target: number[]) {
  const [values, setValues] = React.useState(target)
  const valuesRef = React.useRef(values)
  valuesRef.current = values

  React.useEffect(() => {
    const from = valuesRef.current
    if (
      target.length === from.length &&
      target.every((value, index) => value === from[index])
    )
      return
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setValues(target)
      return
    }
    const start = performance.now()
    let frame = 0
    const step = () => {
      const progress = Math.min(1, (performance.now() - start) / MORPH_DURATION)
      const eased = 1 - (1 - progress) ** 3
      setValues(
        target.map((value, index) => {
          const origin = from[index] ?? value
          return origin + (value - origin) * eased
        })
      )
      if (progress < 1) frame = requestAnimationFrame(step)
    }
    frame = requestAnimationFrame(step)
    return () => cancelAnimationFrame(frame)
  }, [target])

  return values
}

function ActivePoint({
  onChange,
}: {
  onChange: (index: number | null) => void
}) {
  const points = useActiveTooltipDataPoints<Point>()
  const index = points?.[0]?.index ?? null

  React.useEffect(() => onChange(index), [index, onChange])

  return null
}

export function AreaChartInteractive() {
  const id = React.useId()
  const [range, setRange] = React.useState<Range>("1M")
  const [hovered, setHovered] = React.useState<number | null>(null)
  const [released, setReleased] = React.useState(false)
  const active = released ? null : hovered
  const points = React.useMemo(() => resample(generate(range)), [range])
  const target = React.useMemo(
    () => points.map((point) => point.price),
    [points]
  )
  const animated = useMorph(target)
  const data = points.map((point, index) => ({
    ...point,
    price: animated[index],
  }))

  const opening = points[0]
  const current = points[active ?? points.length - 1]
  const baseline = data[0]
  if (!opening || !current || !baseline) return null

  const first = opening.price
  const change = current.price - first
  const rising = change >= 0
  const prices = points.map((point) => point.price)
  const shown = animated
  const min = Math.min(...shown)
  const max = Math.max(...shown)
  const padding = (max - min) * 0.12
  const chartConfig = {
    price: {
      label: "Price",
      color: rising ? "var(--color-green-500)" : "var(--destructive)",
    },
  } satisfies ChartConfig

  const stats = [
    ["Open", priceFormat.format(first)],
    ["High", priceFormat.format(Math.max(...prices))],
    ["Low", priceFormat.format(Math.min(...prices))],
    ["Volume", range === "1D" ? "41.2M" : "1.24B"],
  ]

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="flex flex-wrap items-start justify-between gap-4">
        <div className="flex min-w-56 flex-1 flex-col gap-0.5">
          <p className="flex items-baseline gap-2">
            <span className="text-2xl font-semibold tracking-tight">ACME</span>
            <span className="text-base text-muted-foreground">Acme Corp.</span>
          </p>
          <p className="flex flex-wrap items-baseline gap-2.5">
            <span className="text-3xl font-semibold tracking-tight tabular-nums">
              ${priceFormat.format(current.price)}
            </span>
            <span
              data-trend={rising ? "up" : "down"}
              className="text-base font-semibold tabular-nums data-[trend=down]:text-destructive data-[trend=up]:text-green-600 dark:data-[trend=up]:text-green-400"
            >
              {changeFormat.format(change)} (
              {priceFormat.format(Math.abs((change / first) * 100))}%)
            </span>
          </p>
          <p className="min-h-5 text-sm text-muted-foreground">
            {active === null ? RANGES[range].caption : current.label}
          </p>
        </div>
        <ToggleGroup
          spacing={0}
          value={[range]}
          onValueChange={(value: string[]) => {
            if (value[0]) setRange(value[0] as Range)
          }}
          aria-label="Time range"
        >
          {(Object.keys(RANGES) as Range[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm">
              {key}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <ChartContainer
          config={chartConfig}
          onPointerDownCapture={() => setReleased(false)}
          onPointerUpCapture={(event) => {
            if (event.pointerType !== "mouse") setReleased(true)
          }}
          onPointerCancelCapture={() => setReleased(true)}
          onPointerMoveCapture={(event) => {
            if (event.pointerType === "mouse") setReleased(false)
          }}
          onKeyDownCapture={() => setReleased(false)}
          className="aspect-auto h-72 w-full touch-pan-y [&_.recharts-area-area]:transition-[fill] [&_.recharts-area-curve]:transition-[stroke] [&_.recharts-area-curve]:duration-500 [&_stop]:transition-[stop-color] [&_stop]:duration-500"
        >
          <AreaChart
            accessibilityLayer
            data={data}
            margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
          >
            <defs>
              <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
                <stop
                  offset="0%"
                  stopColor="var(--color-price)"
                  stopOpacity={0.3}
                />
                <stop
                  offset="100%"
                  stopColor="var(--color-price)"
                  stopOpacity={0.02}
                />
              </linearGradient>
            </defs>
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="index"
              type="number"
              domain={["dataMin", "dataMax"]}
              ticks={tickIndexes(points, 6)}
              tickFormatter={(index: number) => points[index]?.tick ?? ""}
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              minTickGap={4}
            />
            <YAxis
              orientation="right"
              domain={[min - padding, max + padding]}
              tickFormatter={(value: number) => Math.round(value).toString()}
              tickLine={false}
              axisLine={false}
              tickMargin={6}
              tickCount={5}
              width={40}
            />
            <ReferenceLine
              y={baseline.price}
              stroke="var(--muted-foreground)"
              strokeDasharray="2 3"
            />
            <ChartTooltip
              active={released ? false : undefined}
              cursor={
                released
                  ? false
                  : { stroke: "var(--muted-foreground)", strokeWidth: 1 }
              }
              content={() => null}
            />
            <Area
              dataKey="price"
              type="linear"
              stroke="var(--color-price)"
              strokeWidth={2}
              fill={`url(#${id})`}
              isAnimationActive={false}
              activeDot={
                released
                  ? false
                  : {
                      r: 5,
                      fill: "var(--color-price)",
                      stroke: "var(--muted)",
                      strokeWidth: 2,
                    }
              }
            />
            <ActivePoint onChange={setHovered} />
          </AreaChart>
        </ChartContainer>
        <dl className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-x-6">
          {stats.map(([term, value]) => (
            <div
              key={term}
              className="flex items-center gap-2 border-b border-border py-1.75 text-sm"
            >
              <dt className="flex-1 text-muted-foreground">{term}</dt>
              <dd className="font-semibold tabular-nums">{value}</dd>
            </div>
          ))}
        </dl>
      </CardContent>
    </Card>
  )
}
```

A stock-style price chart with range toggles from one day to five years. Scrubbing the plot updates the price, change and date in the header, and switching ranges morphs the line into its new shape. Use it as a starting point for financial or any time-series dashboard where readers drill into time ranges.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/area-chart-interactive
```

Works in any project with a `components.json`. See [Installation](https://ui.suiss.app/docs/installation#add-components) for the one-line registry setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add charts area-chart-interactive
```

### Manual

1. Install the dependencies:

```bash title="Terminal"
npm install recharts
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/area-chart-interactive.tsx`:

```tsx title="components/charts/area-chart-interactive.tsx"
"use client"

import * as React from "react"
import {
  Area,
  AreaChart,
  CartesianGrid,
  ReferenceLine,
  useActiveTooltipDataPoints,
  XAxis,
  YAxis,
} from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
} from "@/components/ui/chart"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Range = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y"

type Point = { index: number; price: number; label: string; tick: string }

const RANGES: Record<
  Range,
  {
    points: number
    seed: number
    volatility: number
    drift: number
    caption: string
  }
> = {
  "1D": {
    points: 78,
    seed: 11,
    volatility: 0.0025,
    drift: 0.46,
    caption: "Today",
  },
  "1W": {
    points: 35,
    seed: 23,
    volatility: 0.004,
    drift: 0.46,
    caption: "Past week",
  },
  "1M": {
    points: 22,
    seed: 37,
    volatility: 0.012,
    drift: 0.53,
    caption: "Past month",
  },
  "3M": {
    points: 64,
    seed: 41,
    volatility: 0.014,
    drift: 0.46,
    caption: "Past 3 months",
  },
  "1Y": {
    points: 52,
    seed: 59,
    volatility: 0.03,
    drift: 0.46,
    caption: "Past year",
  },
  "5Y": {
    points: 60,
    seed: 73,
    volatility: 0.045,
    drift: 0.46,
    caption: "Past 5 years",
  },
}

const LAST_PRICE = 254.32
const END = new Date(2026, 9, 6)
const SAMPLES = 80
const MORPH_DURATION = 520

const priceFormat = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
})
const changeFormat = new Intl.NumberFormat("en-US", {
  minimumFractionDigits: 2,
  maximumFractionDigits: 2,
  signDisplay: "always",
})
const dayFormat = new Intl.DateTimeFormat("en-US", {
  month: "short",
  day: "numeric",
})
const weekdayFormat = new Intl.DateTimeFormat("en-US", { weekday: "short" })
const monthFormat = new Intl.DateTimeFormat("en-US", { month: "short" })
const monthYearFormat = new Intl.DateTimeFormat("en-US", {
  month: "long",
  year: "numeric",
})

function businessDays(count: number) {
  const days: Date[] = []
  const day = new Date(END)
  while (days.length < count) {
    if (day.getDay() % 6) days.unshift(new Date(day))
    day.setDate(day.getDate() - 1)
  }
  return days
}

function generate(range: Range): Point[] {
  const { points, seed, volatility, drift } = RANGES[range]
  let state = seed
  let value = 100
  const values = Array.from({ length: points }, () => {
    state = (state * 16807) % 2147483647
    value *= 1 + ((state - 1) / 2147483646 - drift) * volatility
    return value
  })
  const last = values.at(-1)
  if (last === undefined) return []
  const scale = LAST_PRICE / last
  const days = businessDays(points)
  const week = businessDays(5)

  return values.map((raw, index) => {
    const price = Number((raw * scale).toFixed(2))
    if (range === "1D") {
      const minutes = 570 + index * 5
      const hour = Math.floor(minutes / 60)
      const minute = minutes % 60
      const time = `${hour}:${String(minute).padStart(2, "0")}`
      return {
        index,
        price,
        label: time,
        tick: minute === 0 ? `${hour}:00` : "",
      }
    }
    if (range === "1W") {
      const day = week[Math.floor(index / 7)]
      const weekday = weekdayFormat.format(day)
      return {
        index,
        price,
        label: `${weekday} ${10 + (index % 7)}:00`,
        tick: index % 7 === 0 ? weekday : "",
      }
    }
    if (range === "1M" || range === "3M") {
      const label = dayFormat.format(days[index])
      return { index, price, label, tick: label }
    }
    if (range === "1Y") {
      const day = new Date(END)
      day.setDate(day.getDate() - (points - 1 - index) * 7)
      return {
        index,
        price,
        label: `${dayFormat.format(day)}, ${day.getFullYear()}`,
        tick: monthFormat.format(day),
      }
    }
    const month = new Date(
      END.getFullYear(),
      END.getMonth() - (points - 1 - index),
      1
    )
    return {
      index,
      price,
      label: monthYearFormat.format(month),
      tick: String(month.getFullYear()),
    }
  })
}

function resample(source: Point[]) {
  const samples: Point[] = []
  let previous = -1
  for (let index = 0; index < SAMPLES; index++) {
    const position = (index * (source.length - 1)) / (SAMPLES - 1)
    const from = Math.floor(position)
    const to = Math.min(source.length - 1, from + 1)
    const nearest = Math.round(position)
    const start = source[from]
    const end = source[to]
    const closest = source[nearest]
    if (!start || !end || !closest) return samples
    const price = start.price + (end.price - start.price) * (position - from)
    samples.push({
      index,
      price: Number(price.toFixed(2)),
      label: closest.label,
      tick: nearest !== previous ? closest.tick : "",
    })
    previous = nearest
  }
  return samples
}

function tickIndexes(points: Point[], max: number) {
  const ticks = points
    .filter(
      (point, index) => point.tick && point.tick !== points[index - 1]?.tick
    )
    .map((point) => point.index)
  if (ticks.length <= max) return ticks
  const step = Math.ceil(ticks.length / max)
  return ticks.filter((_, index) => index % step === 0)
}

function useMorph(target: number[]) {
  const [values, setValues] = React.useState(target)
  const valuesRef = React.useRef(values)
  valuesRef.current = values

  React.useEffect(() => {
    const from = valuesRef.current
    if (
      target.length === from.length &&
      target.every((value, index) => value === from[index])
    )
      return
    if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
      setValues(target)
      return
    }
    const start = performance.now()
    let frame = 0
    const step = () => {
      const progress = Math.min(1, (performance.now() - start) / MORPH_DURATION)
      const eased = 1 - (1 - progress) ** 3
      setValues(
        target.map((value, index) => {
          const origin = from[index] ?? value
          return origin + (value - origin) * eased
        })
      )
      if (progress < 1) frame = requestAnimationFrame(step)
    }
    frame = requestAnimationFrame(step)
    return () => cancelAnimationFrame(frame)
  }, [target])

  return values
}

function ActivePoint({
  onChange,
}: {
  onChange: (index: number | null) => void
}) {
  const points = useActiveTooltipDataPoints<Point>()
  const index = points?.[0]?.index ?? null

  React.useEffect(() => onChange(index), [index, onChange])

  return null
}

export function AreaChartInteractive() {
  const id = React.useId()
  const [range, setRange] = React.useState<Range>("1M")
  const [hovered, setHovered] = React.useState<number | null>(null)
  const [released, setReleased] = React.useState(false)
  const active = released ? null : hovered
  const points = React.useMemo(() => resample(generate(range)), [range])
  const target = React.useMemo(
    () => points.map((point) => point.price),
    [points]
  )
  const animated = useMorph(target)
  const data = points.map((point, index) => ({
    ...point,
    price: animated[index],
  }))

  const opening = points[0]
  const current = points[active ?? points.length - 1]
  const baseline = data[0]
  if (!opening || !current || !baseline) return null

  const first = opening.price
  const change = current.price - first
  const rising = change >= 0
  const prices = points.map((point) => point.price)
  const shown = animated
  const min = Math.min(...shown)
  const max = Math.max(...shown)
  const padding = (max - min) * 0.12
  const chartConfig = {
    price: {
      label: "Price",
      color: rising ? "var(--color-green-500)" : "var(--destructive)",
    },
  } satisfies ChartConfig

  const stats = [
    ["Open", priceFormat.format(first)],
    ["High", priceFormat.format(Math.max(...prices))],
    ["Low", priceFormat.format(Math.min(...prices))],
    ["Volume", range === "1D" ? "41.2M" : "1.24B"],
  ]

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="flex flex-wrap items-start justify-between gap-4">
        <div className="flex min-w-56 flex-1 flex-col gap-0.5">
          <p className="flex items-baseline gap-2">
            <span className="text-2xl font-semibold tracking-tight">ACME</span>
            <span className="text-base text-muted-foreground">Acme Corp.</span>
          </p>
          <p className="flex flex-wrap items-baseline gap-2.5">
            <span className="text-3xl font-semibold tracking-tight tabular-nums">
              ${priceFormat.format(current.price)}
            </span>
            <span
              data-trend={rising ? "up" : "down"}
              className="text-base font-semibold tabular-nums data-[trend=down]:text-destructive data-[trend=up]:text-green-600 dark:data-[trend=up]:text-green-400"
            >
              {changeFormat.format(change)} (
              {priceFormat.format(Math.abs((change / first) * 100))}%)
            </span>
          </p>
          <p className="min-h-5 text-sm text-muted-foreground">
            {active === null ? RANGES[range].caption : current.label}
          </p>
        </div>
        <ToggleGroup
          spacing={0}
          value={[range]}
          onValueChange={(value: string[]) => {
            if (value[0]) setRange(value[0] as Range)
          }}
          aria-label="Time range"
        >
          {(Object.keys(RANGES) as Range[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm">
              {key}
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <ChartContainer
          config={chartConfig}
          onPointerDownCapture={() => setReleased(false)}
          onPointerUpCapture={(event) => {
            if (event.pointerType !== "mouse") setReleased(true)
          }}
          onPointerCancelCapture={() => setReleased(true)}
          onPointerMoveCapture={(event) => {
            if (event.pointerType === "mouse") setReleased(false)
          }}
          onKeyDownCapture={() => setReleased(false)}
          className="aspect-auto h-72 w-full touch-pan-y [&_.recharts-area-area]:transition-[fill] [&_.recharts-area-curve]:transition-[stroke] [&_.recharts-area-curve]:duration-500 [&_stop]:transition-[stop-color] [&_stop]:duration-500"
        >
          <AreaChart
            accessibilityLayer
            data={data}
            margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
          >
            <defs>
              <linearGradient id={id} x1="0" y1="0" x2="0" y2="1">
                <stop
                  offset="0%"
                  stopColor="var(--color-price)"
                  stopOpacity={0.3}
                />
                <stop
                  offset="100%"
                  stopColor="var(--color-price)"
                  stopOpacity={0.02}
                />
              </linearGradient>
            </defs>
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="index"
              type="number"
              domain={["dataMin", "dataMax"]}
              ticks={tickIndexes(points, 6)}
              tickFormatter={(index: number) => points[index]?.tick ?? ""}
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              minTickGap={4}
            />
            <YAxis
              orientation="right"
              domain={[min - padding, max + padding]}
              tickFormatter={(value: number) => Math.round(value).toString()}
              tickLine={false}
              axisLine={false}
              tickMargin={6}
              tickCount={5}
              width={40}
            />
            <ReferenceLine
              y={baseline.price}
              stroke="var(--muted-foreground)"
              strokeDasharray="2 3"
            />
            <ChartTooltip
              active={released ? false : undefined}
              cursor={
                released
                  ? false
                  : { stroke: "var(--muted-foreground)", strokeWidth: 1 }
              }
              content={() => null}
            />
            <Area
              dataKey="price"
              type="linear"
              stroke="var(--color-price)"
              strokeWidth={2}
              fill={`url(#${id})`}
              isAnimationActive={false}
              activeDot={
                released
                  ? false
                  : {
                      r: 5,
                      fill: "var(--color-price)",
                      stroke: "var(--muted)",
                      strokeWidth: 2,
                    }
              }
            />
            <ActivePoint onChange={setHovered} />
          </AreaChart>
        </ChartContainer>
        <dl className="grid grid-cols-[repeat(auto-fit,minmax(9rem,1fr))] gap-x-6">
          {stats.map(([term, value]) => (
            <div
              key={term}
              className="flex items-center gap-2 border-b border-border py-1.75 text-sm"
            >
              <dt className="flex-1 text-muted-foreground">{term}</dt>
              <dd className="font-semibold tabular-nums">{value}</dd>
            </div>
          ))}
        </dl>
      </CardContent>
    </Card>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { AreaChartInteractive } from "@/components/charts/area-chart-interactive"
```

```tsx
<AreaChartInteractive />
```

The demo generates prices from a seeded random series; replace the `generate` function with your own fetch or data per range, keeping one price and one label per point. Edit `RANGES` to change the available toggles and their captions. The stats row under the chart, including the volume figure, is static and should be wired to your data.

## Notes

The line turns green or red depending on whether the price at the cursor is above or below the opening price, so the color can flip while scrubbing. The morph animation is skipped when the user prefers reduced motion, and on touch devices the header returns to the latest price when the finger lifts. There is no floating tooltip; all readouts live in the header.

**React Native, SwiftUI, Jetpack Compose**

![Preview](https://ui.suiss.app/previews/charts-area--interactive.light.webp)

```tsx title="uniwind/components/charts/area-chart-interactive.tsx"
import { cn } from "cn"
import * as React from "react"
import { View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  Area,
  AreaChart,
  CartesianGrid,
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ReferenceLine,
  XAxis,
  YAxis,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Range = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y"

type Point = { index: number; price: number; label: string; tick: string }

const RANGES: Record<
  Range,
  {
    points: number
    seed: number
    volatility: number
    drift: number
    caption: string
  }
> = {
  "1D": {
    points: 78,
    seed: 11,
    volatility: 0.0025,
    drift: 0.46,
    caption: "Today",
  },
  "1W": {
    points: 35,
    seed: 23,
    volatility: 0.004,
    drift: 0.46,
    caption: "Past week",
  },
  "1M": {
    points: 22,
    seed: 37,
    volatility: 0.012,
    drift: 0.53,
    caption: "Past month",
  },
  "3M": {
    points: 64,
    seed: 41,
    volatility: 0.014,
    drift: 0.46,
    caption: "Past 3 months",
  },
  "1Y": {
    points: 52,
    seed: 59,
    volatility: 0.03,
    drift: 0.46,
    caption: "Past year",
  },
  "5Y": {
    points: 60,
    seed: 73,
    volatility: 0.045,
    drift: 0.46,
    caption: "Past 5 years",
  },
}

const LAST_PRICE = 254.32
const END = new Date(2026, 9, 6)
const SAMPLES = 80

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
]

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]

function shortMonth(date: Date) {
  return (MONTHS[date.getMonth()] ?? "").slice(0, 3)
}

function shortDay(date: Date) {
  return `${shortMonth(date)} ${date.getDate()}`
}

function formatPrice(value: number, signed = false) {
  const sign = signed && value >= 0 ? "+" : ""
  try {
    return `${sign}${new Intl.NumberFormat("en-US", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    }).format(value)}`
  } catch {
    const [whole = "0", fraction = "00"] = value.toFixed(2).split(".")
    return `${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${fraction}`
  }
}

function businessDays(count: number) {
  const days: Date[] = []
  const day = new Date(END)
  while (days.length < count) {
    if (day.getDay() % 6) days.unshift(new Date(day))
    day.setDate(day.getDate() - 1)
  }
  return days
}

function generate(range: Range): Point[] {
  const { points, seed, volatility, drift } = RANGES[range]
  let state = seed
  let value = 100
  const values = Array.from({ length: points }, () => {
    state = (state * 16807) % 2147483647
    value *= 1 + ((state - 1) / 2147483646 - drift) * volatility
    return value
  })
  const last = values.at(-1)
  if (last === undefined) return []
  const scale = LAST_PRICE / last
  const days = businessDays(points)
  const week = businessDays(5)

  return values.map((raw, index) => {
    const price = Number((raw * scale).toFixed(2))
    if (range === "1D") {
      const minutes = 570 + index * 5
      const hour = Math.floor(minutes / 60)
      const minute = minutes % 60
      const time = `${hour}:${String(minute).padStart(2, "0")}`
      return {
        index,
        price,
        label: time,
        tick: minute === 0 ? `${hour}:00` : "",
      }
    }
    if (range === "1W") {
      const day = week[Math.floor(index / 7)] ?? END
      const weekday = WEEKDAYS[day.getDay()] ?? ""
      return {
        index,
        price,
        label: `${weekday} ${10 + (index % 7)}:00`,
        tick: index % 7 === 0 ? weekday : "",
      }
    }
    if (range === "1M" || range === "3M") {
      const label = shortDay(days[index] ?? END)
      return { index, price, label, tick: label }
    }
    if (range === "1Y") {
      const day = new Date(END)
      day.setDate(day.getDate() - (points - 1 - index) * 7)
      return {
        index,
        price,
        label: `${shortDay(day)}, ${day.getFullYear()}`,
        tick: shortMonth(day),
      }
    }
    const month = new Date(
      END.getFullYear(),
      END.getMonth() - (points - 1 - index),
      1
    )
    return {
      index,
      price,
      label: `${MONTHS[month.getMonth()]} ${month.getFullYear()}`,
      tick: String(month.getFullYear()),
    }
  })
}

function resample(source: Point[]) {
  const samples: Point[] = []
  let previous = -1
  for (let index = 0; index < SAMPLES; index++) {
    const position = (index * (source.length - 1)) / (SAMPLES - 1)
    const from = Math.floor(position)
    const to = Math.min(source.length - 1, from + 1)
    const nearest = Math.round(position)
    const start = source[from]
    const end = source[to]
    const closest = source[nearest]
    if (!start || !end || !closest) return samples
    const price = start.price + (end.price - start.price) * (position - from)
    samples.push({
      index,
      price: Number(price.toFixed(2)),
      label: closest.label,
      tick: nearest !== previous ? closest.tick : "",
    })
    previous = nearest
  }
  return samples
}

function tickIndexes(points: Point[], max: number) {
  const ticks = points
    .filter(
      (point, index) => point.tick && point.tick !== points[index - 1]?.tick
    )
    .map((point) => point.index)
  if (ticks.length <= max) return ticks
  const step = Math.ceil(ticks.length / max)
  return ticks.filter((_, index) => index % step === 0)
}

export function AreaChartInteractive() {
  const [range, setRange] = React.useState<Range>("1M")
  const [active, setActive] = React.useState<number | null>(null)
  const points = React.useMemo(() => resample(generate(range)), [range])

  const opening = points[0]
  const current = points[active ?? points.length - 1]
  if (!opening || !current) return null

  const first = opening.price
  const change = current.price - first
  const rising = change >= 0
  const prices = points.map((point) => point.price)
  const min = Math.min(...prices)
  const max = Math.max(...prices)
  const padding = (max - min) * 0.12
  const color = rising ? "green-500" : "destructive"
  const chartConfig = {
    price: { label: "Price", color },
  } satisfies ChartConfig

  const stats = [
    ["Open", formatPrice(first)],
    ["High", formatPrice(max)],
    ["Low", formatPrice(min)],
    ["Volume", range === "1D" ? "41.2M" : "1.24B"],
  ]

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="flex-row flex-wrap items-start justify-between gap-4">
        <View className="min-w-56 grow gap-0.5">
          <View className="flex-row items-baseline gap-2">
            <Text className="text-2xl font-semibold tracking-tight">ACME</Text>
            <Text className="text-base text-muted-foreground">Acme Corp.</Text>
          </View>
          <View className="flex-row flex-wrap items-baseline gap-2.5">
            <Text className="text-3xl font-semibold tracking-tight tabular-nums">
              ${formatPrice(current.price)}
            </Text>
            <Text
              className={cn(
                "text-base font-semibold tabular-nums",
                rising
                  ? "text-green-600 dark:text-green-400"
                  : "text-destructive"
              )}
            >
              {formatPrice(change, true)} (
              {formatPrice(Math.abs((change / first) * 100))}%)
            </Text>
          </View>
          <Text className="min-h-5 text-sm text-muted-foreground">
            {active === null ? RANGES[range].caption : current.label}
          </Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[range]}
          onValueChange={(value) => {
            if (value[0]) setRange(value[0] as Range)
          }}
          accessibilityLabel="Time range"
        >
          {(Object.keys(RANGES) as Range[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm">
              <Text>{key}</Text>
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="gap-4">
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="ACME price"
          className="h-72 w-full"
        >
          <AreaChart
            data={points}
            margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
            onActiveIndexChange={setActive}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="index"
              type="number"
              domain={["dataMin", "dataMax"]}
              ticks={tickIndexes(points, 6)}
              tickFormatter={(index: number) => points[index]?.tick ?? ""}
              tickMargin={8}
              minTickGap={4}
            />
            <YAxis
              orientation="right"
              domain={[min - padding, max + padding]}
              tickFormatter={(value: number) => Math.round(value).toString()}
              tickMargin={6}
              tickCount={5}
              width={40}
            />
            <ReferenceLine
              y={first}
              stroke="muted-foreground"
              strokeDasharray="2 3"
            />
            <ChartTooltip
              content={() => null}
              cursor={{ stroke: "muted-foreground", strokeWidth: 1 }}
            />
            <Area
              isAnimationActive={false}
              dataKey="price"
              type="linear"
              strokeWidth={2}
              fill={{
                stops: [
                  { offset: 0, opacity: 0.3 },
                  { offset: 1, opacity: 0.02 },
                ],
              }}
              activeDot={{
                r: 5,
                fill: color,
                stroke: "muted",
                strokeWidth: 2,
              }}
            />
          </AreaChart>
        </ChartContainer>
        <View className="flex-row flex-wrap gap-x-6">
          {stats.map(([term, value]) => (
            <View
              key={term}
              className="min-w-36 grow basis-0 flex-row items-center gap-2 border-b border-border py-1.75"
            >
              <Text className="grow text-sm text-muted-foreground">{term}</Text>
              <Text className="text-sm font-semibold tabular-nums">
                {value}
              </Text>
            </View>
          ))}
        </View>
      </CardContent>
    </Card>
  )
}
```

```swift title="swift/Charts/AreaChartInteractive.swift"
import Charts
import SwiftUI

private enum Period: String, CaseIterable {
  case day = "1D"
  case week = "1W"
  case month = "1M"
  case quarter = "3M"
  case year = "1Y"
  case fiveYears = "5Y"

  var settings: (points: Int, seed: Int, volatility: Double, drift: Double) {
    switch self {
    case .day: (78, 11, 0.0025, 0.46)
    case .week: (35, 23, 0.004, 0.46)
    case .month: (22, 37, 0.012, 0.53)
    case .quarter: (64, 41, 0.014, 0.46)
    case .year: (52, 59, 0.03, 0.46)
    case .fiveYears: (60, 73, 0.045, 0.46)
    }
  }

  var caption: String {
    switch self {
    case .day: "Today"
    case .week: "Past week"
    case .month: "Past month"
    case .quarter: "Past 3 months"
    case .year: "Past year"
    case .fiveYears: "Past 5 years"
    }
  }
}

private struct Point {
  let index: Int
  let price: Double
  let label: String
  let tick: String
}

private let lastPrice = 254.32
private let samples = 80

private var calendar: Calendar {
  Calendar(identifier: .gregorian)
}

private var end: Date {
  calendar.date(from: DateComponents(year: 2026, month: 10, day: 6)) ?? .now
}

private func businessDays(_ count: Int) -> [Date] {
  var days: [Date] = []
  var day = end
  while days.count < count {
    let weekday = calendar.component(.weekday, from: day)
    if weekday != 1 && weekday != 7 { days.insert(day, at: 0) }
    day = calendar.date(byAdding: .day, value: -1, to: day) ?? day
  }
  return days
}

private func generate(_ range: Period, locale: Locale) -> [Point] {
  let settings = range.settings
  var state = settings.seed
  var value = 100.0
  let values = (0..<settings.points).map { _ in
    state = (state * 16807) % 2147483647
    value *= 1 + (Double(state - 1) / 2147483646 - settings.drift) * settings.volatility
    return value
  }
  guard let last = values.last else { return [] }
  let scale = lastPrice / last
  let days = businessDays(settings.points)
  let week = businessDays(5)
  let dayFormat = Date.FormatStyle.dateTime.month(.abbreviated).day().locale(locale)
  return values.enumerated().map { index, raw in
    let price = (raw * scale * 100).rounded() / 100
    switch range {
    case .day:
      let minutes = 570 + index * 5
      let hour = minutes / 60
      let minute = minutes % 60
      return Point(index: index, price: price, label: String(format: "%d:%02d", hour, minute), tick: minute == 0 ? "\(hour):00" : "")
    case .week:
      let weekday = week[index / 7].formatted(.dateTime.weekday(.abbreviated).locale(locale))
      return Point(index: index, price: price, label: "\(weekday) \(10 + index % 7):00", tick: index % 7 == 0 ? weekday : "")
    case .month, .quarter:
      let label = days[index].formatted(dayFormat)
      return Point(index: index, price: price, label: label, tick: label)
    case .year:
      let day = calendar.date(byAdding: .day, value: -(settings.points - 1 - index) * 7, to: end) ?? end
      return Point(
        index: index,
        price: price,
        label: "\(day.formatted(dayFormat)), \(calendar.component(.year, from: day))",
        tick: day.formatted(.dateTime.month(.abbreviated).locale(locale))
      )
    case .fiveYears:
      let month = calendar.date(byAdding: .month, value: -(settings.points - 1 - index), to: end) ?? end
      return Point(
        index: index,
        price: price,
        label: month.formatted(.dateTime.month(.wide).year().locale(locale)),
        tick: "\(calendar.component(.year, from: month))"
      )
    }
  }
}

private func resample(_ source: [Point]) -> [Point] {
  guard source.count > 1 else { return source }
  var result: [Point] = []
  var previous = -1
  for index in 0..<samples {
    let position = Double(index * (source.count - 1)) / Double(samples - 1)
    let from = Int(position)
    let to = min(source.count - 1, from + 1)
    let nearest = Int(position.rounded())
    let start = source[from]
    let finish = source[to]
    let closest = source[nearest]
    let price = start.price + (finish.price - start.price) * (position - Double(from))
    result.append(Point(index: index, price: (price * 100).rounded() / 100, label: closest.label, tick: nearest != previous ? closest.tick : ""))
    previous = nearest
  }
  return result
}

private func tickIndexes(_ points: [Point], max: Int) -> [Int] {
  let ticks = points.enumerated()
    .filter { index, point in !point.tick.isEmpty && (index == 0 || point.tick != points[index - 1].tick) }
    .map(\.element.index)
  guard ticks.count > max else { return ticks }
  let step = Int((Double(ticks.count) / Double(max)).rounded(.up))
  return ticks.enumerated().filter { $0.offset % step == 0 }.map(\.element)
}

public struct AreaChartInteractive: View {
  @State private var range: Period = .month
  @State private var selection: Int?
  @Environment(\.locale) private var locale
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init() {}

  private var rangeBinding: Binding<Period?> {
    Binding(get: { range }, set: { if let value = $0 { range = value } })
  }

  public var body: some View {
    let points = resample(generate(range, locale: locale))
    let prices = points.map(\.price)
    let first = points.first?.price ?? 0
    let current = points[selection ?? points.count - 1]
    let change = current.price - first
    let rising = change >= 0
    let low = prices.min() ?? 0
    let high = prices.max() ?? 1
    let padding = (high - low) * 0.12
    let chartConfig: ChartConfig = ["price": .init("Price", color: rising ? Palette.green500 : EsuissColor.destructive)]
    let color = chartConfig.color("price")
    let price = FloatingPointFormatStyle<Double>.number.precision(.fractionLength(2)).locale(locale)
    let stats: [(String, String)] = [
      ("Open", first.formatted(price)),
      ("High", high.formatted(price)),
      ("Low", low.formatted(price)),
      ("Volume", range == .day ? "41.2M" : "1.24B"),
    ]
    Card(.filled, size: .sm) {
      VStack(alignment: .leading, spacing: 16) {
        VStack(alignment: .leading, spacing: 2) {
          HStack(alignment: .firstTextBaseline, spacing: 8) {
            Text(verbatim: "ACME")
              .textStyle(.xl2, weight: .semibold)
            Text(verbatim: "Acme Corp.")
              .textStyle(.base)
              .foregroundStyle(EsuissColor.mutedForeground)
          }
          HStack(alignment: .firstTextBaseline, spacing: 10) {
            Text(verbatim: "$\(current.price.formatted(price))")
              .textStyle(.xl3, weight: .semibold)
              .monospacedDigit()
            Text(verbatim: "\(change.formatted(price.sign(strategy: .always()))) (\(abs(change / first * 100).formatted(price))%)")
              .textStyle(.base, weight: .semibold)
              .monospacedDigit()
              .foregroundStyle(rising ? Color.adaptive(light: Palette.green600, dark: Palette.green400) : EsuissColor.destructive)
          }
          Text(LocalizedStringKey(selection == nil ? range.caption : current.label))
            .textStyle(.sm)
            .foregroundStyle(EsuissColor.mutedForeground)
            .frame(minHeight: 20)
        }
        ToggleGroup(selection: rangeBinding, size: .sm, spacing: 0) {
          ForEach(Period.allCases, id: \.self) { item in
            ToggleGroupItem(LocalizedStringKey(item.rawValue), value: item)
          }
        }
        .accessibilityLabel("Time range")
      }
      .padding(.horizontal, 16)
      CardContent {
        VStack(alignment: .leading, spacing: 16) {
          ChartContainer(config: chartConfig) {
            Chart {
              RuleMark(y: .value("Open", first))
                .lineStyle(StrokeStyle(lineWidth: 1, dash: [2, 3]))
                .foregroundStyle(EsuissColor.mutedForeground)
              ForEach(points, id: \.index) { point in
                AreaMark(x: .value("Time", point.index), yStart: .value("Base", low - padding), yEnd: .value("Price", point.price))
                  .interpolationMethod(.linear)
                  .foregroundStyle(LinearGradient(colors: [color.opacity(0.3), color.opacity(0.02)], startPoint: .top, endPoint: .bottom))
                LineMark(x: .value("Time", point.index), y: .value("Price", point.price))
                  .interpolationMethod(.linear)
                  .lineStyle(StrokeStyle(lineWidth: 2, lineCap: .round, lineJoin: .round))
                  .foregroundStyle(color)
              }
              if let selection {
                PointMark(x: .value("Time", selection), y: .value("Price", points[selection].price))
                  .symbol {
                    Circle()
                      .fill(color)
                      .stroke(EsuissColor.muted, lineWidth: 2)
                      .frame(width: 12, height: 12)
                  }
              }
            }
            .chartXScale(domain: 0...(points.count - 1))
            .chartYScale(domain: (low - padding)...(high + padding))
            .chartXAxis { ChartAxis.x(values: tickIndexes(points, max: 6)) { points[$0].tick } }
            .chartYAxis { ChartAxis.y(count: 5) { "\(Int($0.rounded()))" } }
            .animation(reduceMotion ? nil : .easeOut(duration: 0.52), value: range)
            .chartTooltip(
              x: points.map(\.index),
              labels: points.map(\.label),
              selection: $selection,
              accessibilityLabel: "ACME",
              items: { [ChartTooltipItem("price", value: points[$0].price)] }
            ) { _ in
              EmptyView()
            }
          }
          .frame(height: 288)
          LazyVGrid(columns: [GridItem(.adaptive(minimum: 144), spacing: 24)], spacing: 0) {
            ForEach(stats, id: \.0) { term, value in
              HStack(spacing: 8) {
                Text(LocalizedStringKey(term))
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .frame(maxWidth: .infinity, alignment: .leading)
                Text(verbatim: value)
                  .fontWeight(.semibold)
                  .monospacedDigit()
              }
              .textStyle(.sm)
              .padding(.vertical, 7)
              .overlay(alignment: .bottom) {
                EsuissColor.border.frame(height: 1)
              }
              .accessibilityElement(children: .combine)
            }
          }
        }
      }
    }
  }
}
```

```kt title="kotlin/charts/AreaChartInteractive.kt"
package com.esuiss.ui.charts

import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.Easing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import com.esuiss.ui.components.AreaChart
import com.esuiss.ui.components.Card
import com.esuiss.ui.components.CardContent
import com.esuiss.ui.components.CardSize
import com.esuiss.ui.components.CardVariant
import com.esuiss.ui.components.ChartAxisOrientation
import com.esuiss.ui.components.ChartConfig
import com.esuiss.ui.components.ChartConfigItem
import com.esuiss.ui.components.ChartContainer
import com.esuiss.ui.components.ChartCursor
import com.esuiss.ui.components.ChartDot
import com.esuiss.ui.components.ChartFill
import com.esuiss.ui.components.ChartMargin
import com.esuiss.ui.components.Separator
import com.esuiss.ui.components.ToggleGroup
import com.esuiss.ui.components.ToggleGroupItem
import com.esuiss.ui.components.ToggleSize
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Typography
import java.text.NumberFormat
import java.text.SimpleDateFormat
import java.util.Calendar
import java.util.Date
import java.util.Locale
import kotlin.math.abs
import kotlin.math.ceil
import kotlin.math.roundToInt

private class StockRange(val points: Int, val seed: Long, val volatility: Double, val drift: Double, val caption: String)

private class PricePoint(val index: Int, val price: Double, val label: String, val tick: String)

private val ranges = linkedMapOf(
    "1D" to StockRange(78, 11, 0.0025, 0.46, "Today"),
    "1W" to StockRange(35, 23, 0.004, 0.46, "Past week"),
    "1M" to StockRange(22, 37, 0.012, 0.53, "Past month"),
    "3M" to StockRange(64, 41, 0.014, 0.46, "Past 3 months"),
    "1Y" to StockRange(52, 59, 0.03, 0.46, "Past year"),
    "5Y" to StockRange(60, 73, 0.045, 0.46, "Past 5 years"),
)

private const val LAST_PRICE = 254.32

private const val SAMPLES = 80

private const val MORPH_DURATION = 520

private val MorphEasing = Easing { 1f - (1f - it) * (1f - it) * (1f - it) }

private fun end(): Calendar = Calendar.getInstance().apply {
    clear()
    set(2026, Calendar.OCTOBER, 6, 12, 0)
}

private fun format(pattern: String, date: Date) = SimpleDateFormat(pattern, Locale.US).format(date)

private fun businessDays(count: Int): List<Date> {
    val days = ArrayDeque<Date>()
    val day = end()
    while (days.size < count) {
        val weekday = day.get(Calendar.DAY_OF_WEEK)
        if (weekday != Calendar.SATURDAY && weekday != Calendar.SUNDAY) days.addFirst(day.time)
        day.add(Calendar.DAY_OF_MONTH, -1)
    }
    return days.toList()
}

private fun generate(range: String): List<PricePoint> {
    val spec = ranges.getValue(range)
    var state = spec.seed
    var value = 100.0
    val values = List(spec.points) {
        state = state * 16807 % 2147483647
        value *= 1 + ((state - 1) / 2147483646.0 - spec.drift) * spec.volatility
        value
    }
    val scale = LAST_PRICE / values.last()
    val days = businessDays(spec.points)
    val week = businessDays(5)
    return values.mapIndexed { index, raw ->
        val price = (raw * scale * 100).roundToInt() / 100.0
        when (range) {
            "1D" -> {
                val minutes = 570 + index * 5
                val hour = minutes / 60
                val minute = minutes % 60
                PricePoint(index, price, "$hour:${minute.toString().padStart(2, '0')}", if (minute == 0) "$hour:00" else "")
            }
            "1W" -> {
                val weekday = format("EEE", week[index / 7])
                PricePoint(index, price, "$weekday ${10 + index % 7}:00", if (index % 7 == 0) weekday else "")
            }
            "1M", "3M" -> {
                val label = format("MMM d", days[index])
                PricePoint(index, price, label, label)
            }
            "1Y" -> {
                val day = end().apply { add(Calendar.DAY_OF_MONTH, -(spec.points - 1 - index) * 7) }
                PricePoint(index, price, "${format("MMM d", day.time)}, ${day.get(Calendar.YEAR)}", format("MMM", day.time))
            }
            else -> {
                val month = end().apply {
                    set(Calendar.DAY_OF_MONTH, 1)
                    add(Calendar.MONTH, -(spec.points - 1 - index))
                }
                PricePoint(index, price, format("MMMM yyyy", month.time), month.get(Calendar.YEAR).toString())
            }
        }
    }
}

private fun resample(source: List<PricePoint>): List<PricePoint> {
    val samples = mutableListOf<PricePoint>()
    var previous = -1
    for (index in 0 until SAMPLES) {
        val position = index * (source.size - 1).toDouble() / (SAMPLES - 1)
        val from = position.toInt()
        val to = minOf(source.size - 1, from + 1)
        val nearest = position.roundToInt()
        val start = source[from]
        val stop = source[to]
        val closest = source[nearest]
        val price = start.price + (stop.price - start.price) * (position - from)
        samples += PricePoint(index, (price * 100).roundToInt() / 100.0, closest.label, if (nearest != previous) closest.tick else "")
        previous = nearest
    }
    return samples
}

private fun tickIndexes(points: List<PricePoint>, max: Int): List<Int> {
    val ticks = points.filterIndexed { index, point -> point.tick.isNotEmpty() && point.tick != points.getOrNull(index - 1)?.tick }.map { it.index }
    if (ticks.size <= max) return ticks
    val step = ceil(ticks.size / max.toDouble()).toInt()
    return ticks.filterIndexed { index, _ -> index % step == 0 }
}

private fun twoDigits(): NumberFormat = NumberFormat.getNumberInstance(Locale.US).apply {
    minimumFractionDigits = 2
    maximumFractionDigits = 2
}

@Composable
private fun rememberMorph(target: List<Double>): List<Double> {
    val reduced = formReducedMotion()
    var values by remember { mutableStateOf(target) }
    LaunchedEffect(target) {
        val from = values
        if (reduced || from == target) {
            values = target
            return@LaunchedEffect
        }
        Animatable(0f).animateTo(1f, tween(MORPH_DURATION, easing = MorphEasing)) {
            values = target.mapIndexed { index, price ->
                val origin = from.getOrNull(index) ?: price
                origin + (price - origin) * value
            }
        }
    }
    return values
}

@Composable
fun AreaChartInteractive(modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    var range by remember { mutableStateOf("1M") }
    var active by remember { mutableStateOf<Int?>(null) }
    val points = remember(range) { resample(generate(range)) }
    val ticks = remember(points) { tickIndexes(points, 6) }
    val priceFormat = remember { twoDigits() }
    val first = points.first().price
    val current = points[active ?: (points.size - 1)]
    val change = current.price - first
    val rising = change >= 0
    val prices = points.map { it.price }
    val animated = rememberMorph(prices)
    val data = points.mapIndexed { index, point -> PricePoint(point.index, animated.getOrElse(index) { point.price }, point.label, point.tick) }
    val min = prices.min()
    val max = prices.max()
    val low = animated.min()
    val high = animated.max()
    val padding = (high - low) * 0.12
    val trend by animateColorAsState(if (rising) (if (colors.isDark) Palette.green400 else Palette.green600) else colors.destructive, tween(500), label = "trend")
    val chartConfig = ChartConfig("price" to ChartConfigItem("Price") { trend })
    val stats = listOf(
        "Open" to priceFormat.format(first),
        "High" to priceFormat.format(max),
        "Low" to priceFormat.format(min),
        "Volume" to if (range == "1D") "41.2M" else "1.24B",
    )
    Card(modifier, variant = CardVariant.Filled, size = CardSize.Sm) {
        CardContent {
            Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
                Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
                    Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
                        BasicText("ACME", Modifier.alignByBaseline(), style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                        BasicText("Acme Corp.", Modifier.alignByBaseline(), style = Typography.base.copy(color = colors.mutedForeground))
                    }
                    Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
                        BasicText(
                            "$${priceFormat.format(current.price)}",
                            Modifier.alignByBaseline(),
                            style = Typography.xl3.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                        )
                        BasicText(
                            "${if (rising) "+" else "-"}${priceFormat.format(abs(change))} (${priceFormat.format(abs(change / first * 100))}%)",
                            Modifier.alignByBaseline(),
                            style = Typography.base.copy(
                                color = if (rising) (if (colors.isDark) Palette.green400 else Palette.green600) else colors.destructive,
                                fontWeight = FontWeight.SemiBold,
                                fontFeatureSettings = "tnum",
                            ),
                        )
                    }
                    BasicText(
                        if (active == null) ranges.getValue(range).caption else current.label,
                        Modifier.heightIn(min = 20.dp),
                        style = Typography.sm.copy(color = colors.mutedForeground),
                    )
                }
                ToggleGroup(
                    value = range,
                    onValueChange = { value ->
                        if (value != null) {
                            range = value
                            active = null
                        }
                    },
                    size = ToggleSize.Sm,
                    spacing = 0,
                ) {
                    for (key in ranges.keys) ToggleGroupItem(key, key)
                }
            }
        }
        CardContent {
            Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
                ChartContainer(
                    chartConfig,
                    Modifier
                        .fillMaxWidth()
                        .height(288.dp),
                ) {
                    val tickByLabel = points.filter { it.index in ticks }.associate { it.label to it.tick }
                    AreaChart(
                        data,
                        margin = ChartMargin(top = 10.dp, right = 0.dp, bottom = 0.dp, left = 0.dp),
                        accessibilityLabel = "ACME, ${ranges.getValue(range).caption}",
                        selectedIndex = active,
                        onSelectedIndexChange = { active = it },
                    ) {
                        CartesianGrid(strokeDasharray = listOf(2f, 4f))
                        XAxis(label = { it.label }, tickMargin = 8.dp, ticks = ticks, tickFormatter = { tickByLabel[it] ?: "" })
                        YAxis(
                            orientation = ChartAxisOrientation.Right,
                            tickMargin = 6.dp,
                            width = 40.dp,
                            domain = (low - padding)..(high + padding),
                            tickFormatter = { it.roundToInt().toString() },
                        )
                        ReferenceLine(first, stroke = colors.mutedForeground, strokeDasharray = listOf(2f, 3f))
                        ChartTooltip(cursor = ChartCursor(colors.mutedForeground)) { }
                        Area(
                            "price",
                            value = { it.price },
                            strokeWidth = 2.dp,
                            fill = ChartFill.VerticalGradient(topAlpha = 0.3f, bottomAlpha = 0.02f),
                            activeDot = ChartDot(5.dp, strokeColor = colors.muted, strokeWidth = 2.dp),
                            isAnimationActive = false,
                        )
                    }
                }
                Column {
                    for (pair in stats.chunked(2)) {
                        Row(horizontalArrangement = Arrangement.spacedBy(24.dp)) {
                            for ((term, value) in pair) {
                                Column(Modifier.weight(1f)) {
                                    Row(Modifier.padding(vertical = 7.dp)) {
                                        BasicText(term, Modifier.weight(1f), style = Typography.sm.copy(color = colors.mutedForeground))
                                        BasicText(
                                            value,
                                            style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                                        )
                                    }
                                    Separator()
                                }
                            }
                        }
                    }
                }
            }
        }
    }
}
```

One price series drawn as a linear area with a fading gradient, a value axis on the right and a dashed `ReferenceLine` at the opening price. A `ToggleGroup` switches between 1D, 1W, 1M, 3M, 1Y and 5Y. Touching the plot reports the point through `onActiveIndexChange`, and the header updates the price, change and date for that point. The area turns green or red depending on whether the price is up or down, and open, high, low and volume sit below the chart. The area sets `isAnimationActive={false}`, so it draws without the enter animation and switches ranges instantly.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/area-chart-interactive
```

See [Installation](https://ui.suiss.app/docs/installation) for the `components.json` setup. Or use the suiss/ui CLI:

```bash title="Terminal"
npx @esuiss/ui@latest add area-chart-interactive
```

**SwiftUI**

```bash title="Terminal"
npx @esuiss/ui@latest add area-chart-interactive --platform swift
```

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add area-chart-interactive --platform kotlin
```

The CLI also adds what it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

### Manual

**React Native**

**Uniwind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/area-chart-interactive.tsx`:

- `uniwind/components/charts/area-chart-interactive.tsx`: shown above

4. Update the import paths to match your project.

**NativeWind**

1. Install the dependencies:

```bash title="Terminal"
npx expo install cn
```

2. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

3. Copy the source into `components/charts/area-chart-interactive.tsx`:

```tsx title="nativewind/components/charts/area-chart-interactive.tsx"
import { cn } from "cn"
import * as React from "react"
import { View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  Area,
  AreaChart,
  CartesianGrid,
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ReferenceLine,
  XAxis,
  YAxis,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Range = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y"

type Point = { index: number; price: number; label: string; tick: string }

const RANGES: Record<
  Range,
  {
    points: number
    seed: number
    volatility: number
    drift: number
    caption: string
  }
> = {
  "1D": {
    points: 78,
    seed: 11,
    volatility: 0.0025,
    drift: 0.46,
    caption: "Today",
  },
  "1W": {
    points: 35,
    seed: 23,
    volatility: 0.004,
    drift: 0.46,
    caption: "Past week",
  },
  "1M": {
    points: 22,
    seed: 37,
    volatility: 0.012,
    drift: 0.53,
    caption: "Past month",
  },
  "3M": {
    points: 64,
    seed: 41,
    volatility: 0.014,
    drift: 0.46,
    caption: "Past 3 months",
  },
  "1Y": {
    points: 52,
    seed: 59,
    volatility: 0.03,
    drift: 0.46,
    caption: "Past year",
  },
  "5Y": {
    points: 60,
    seed: 73,
    volatility: 0.045,
    drift: 0.46,
    caption: "Past 5 years",
  },
}

const LAST_PRICE = 254.32
const END = new Date(2026, 9, 6)
const SAMPLES = 80

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
]

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]

function shortMonth(date: Date) {
  return (MONTHS[date.getMonth()] ?? "").slice(0, 3)
}

function shortDay(date: Date) {
  return `${shortMonth(date)} ${date.getDate()}`
}

function formatPrice(value: number, signed = false) {
  const sign = signed && value >= 0 ? "+" : ""
  try {
    return `${sign}${new Intl.NumberFormat("en-US", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    }).format(value)}`
  } catch {
    const [whole = "0", fraction = "00"] = value.toFixed(2).split(".")
    return `${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${fraction}`
  }
}

function businessDays(count: number) {
  const days: Date[] = []
  const day = new Date(END)
  while (days.length < count) {
    if (day.getDay() % 6) days.unshift(new Date(day))
    day.setDate(day.getDate() - 1)
  }
  return days
}

function generate(range: Range): Point[] {
  const { points, seed, volatility, drift } = RANGES[range]
  let state = seed
  let value = 100
  const values = Array.from({ length: points }, () => {
    state = (state * 16807) % 2147483647
    value *= 1 + ((state - 1) / 2147483646 - drift) * volatility
    return value
  })
  const last = values.at(-1)
  if (last === undefined) return []
  const scale = LAST_PRICE / last
  const days = businessDays(points)
  const week = businessDays(5)

  return values.map((raw, index) => {
    const price = Number((raw * scale).toFixed(2))
    if (range === "1D") {
      const minutes = 570 + index * 5
      const hour = Math.floor(minutes / 60)
      const minute = minutes % 60
      const time = `${hour}:${String(minute).padStart(2, "0")}`
      return {
        index,
        price,
        label: time,
        tick: minute === 0 ? `${hour}:00` : "",
      }
    }
    if (range === "1W") {
      const day = week[Math.floor(index / 7)] ?? END
      const weekday = WEEKDAYS[day.getDay()] ?? ""
      return {
        index,
        price,
        label: `${weekday} ${10 + (index % 7)}:00`,
        tick: index % 7 === 0 ? weekday : "",
      }
    }
    if (range === "1M" || range === "3M") {
      const label = shortDay(days[index] ?? END)
      return { index, price, label, tick: label }
    }
    if (range === "1Y") {
      const day = new Date(END)
      day.setDate(day.getDate() - (points - 1 - index) * 7)
      return {
        index,
        price,
        label: `${shortDay(day)}, ${day.getFullYear()}`,
        tick: shortMonth(day),
      }
    }
    const month = new Date(
      END.getFullYear(),
      END.getMonth() - (points - 1 - index),
      1
    )
    return {
      index,
      price,
      label: `${MONTHS[month.getMonth()]} ${month.getFullYear()}`,
      tick: String(month.getFullYear()),
    }
  })
}

function resample(source: Point[]) {
  const samples: Point[] = []
  let previous = -1
  for (let index = 0; index < SAMPLES; index++) {
    const position = (index * (source.length - 1)) / (SAMPLES - 1)
    const from = Math.floor(position)
    const to = Math.min(source.length - 1, from + 1)
    const nearest = Math.round(position)
    const start = source[from]
    const end = source[to]
    const closest = source[nearest]
    if (!start || !end || !closest) return samples
    const price = start.price + (end.price - start.price) * (position - from)
    samples.push({
      index,
      price: Number(price.toFixed(2)),
      label: closest.label,
      tick: nearest !== previous ? closest.tick : "",
    })
    previous = nearest
  }
  return samples
}

function tickIndexes(points: Point[], max: number) {
  const ticks = points
    .filter(
      (point, index) => point.tick && point.tick !== points[index - 1]?.tick
    )
    .map((point) => point.index)
  if (ticks.length <= max) return ticks
  const step = Math.ceil(ticks.length / max)
  return ticks.filter((_, index) => index % step === 0)
}

export function AreaChartInteractive() {
  const [range, setRange] = React.useState<Range>("1M")
  const [active, setActive] = React.useState<number | null>(null)
  const points = React.useMemo(() => resample(generate(range)), [range])

  const opening = points[0]
  const current = points[active ?? points.length - 1]
  if (!opening || !current) return null

  const first = opening.price
  const change = current.price - first
  const rising = change >= 0
  const prices = points.map((point) => point.price)
  const min = Math.min(...prices)
  const max = Math.max(...prices)
  const padding = (max - min) * 0.12
  const color = rising ? "green-500" : "destructive"
  const chartConfig = {
    price: { label: "Price", color },
  } satisfies ChartConfig

  const stats = [
    ["Open", formatPrice(first)],
    ["High", formatPrice(max)],
    ["Low", formatPrice(min)],
    ["Volume", range === "1D" ? "41.2M" : "1.24B"],
  ]

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="flex-row flex-wrap items-start justify-between gap-4">
        <View className="min-w-56 grow gap-0.5">
          <View className="flex-row items-baseline gap-2">
            <Text className="text-2xl font-semibold tracking-tight">ACME</Text>
            <Text className="text-base text-muted-foreground">Acme Corp.</Text>
          </View>
          <View className="flex-row flex-wrap items-baseline gap-2.5">
            <Text className="text-3xl font-semibold tracking-tight tabular-nums">
              ${formatPrice(current.price)}
            </Text>
            <Text
              className={cn(
                "text-base font-semibold tabular-nums",
                rising
                  ? "text-green-600 dark:text-green-400"
                  : "text-destructive"
              )}
            >
              {formatPrice(change, true)} (
              {formatPrice(Math.abs((change / first) * 100))}%)
            </Text>
          </View>
          <Text className="min-h-5 text-sm text-muted-foreground">
            {active === null ? RANGES[range].caption : current.label}
          </Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[range]}
          onValueChange={(value) => {
            if (value[0]) setRange(value[0] as Range)
          }}
          accessibilityLabel="Time range"
        >
          {(Object.keys(RANGES) as Range[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm">
              <Text>{key}</Text>
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent className="gap-4">
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="ACME price"
          className="h-72 w-full"
        >
          <AreaChart
            data={points}
            margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
            onActiveIndexChange={setActive}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="index"
              type="number"
              domain={["dataMin", "dataMax"]}
              ticks={tickIndexes(points, 6)}
              tickFormatter={(index: number) => points[index]?.tick ?? ""}
              tickMargin={8}
              minTickGap={4}
            />
            <YAxis
              orientation="right"
              domain={[min - padding, max + padding]}
              tickFormatter={(value: number) => Math.round(value).toString()}
              tickMargin={6}
              tickCount={5}
              width={40}
            />
            <ReferenceLine
              y={first}
              stroke="muted-foreground"
              strokeDasharray="2 3"
            />
            <ChartTooltip
              content={() => null}
              cursor={{ stroke: "muted-foreground", strokeWidth: 1 }}
            />
            <Area
              isAnimationActive={false}
              dataKey="price"
              type="linear"
              strokeWidth={2}
              fill={{
                stops: [
                  { offset: 0, opacity: 0.3 },
                  { offset: 1, opacity: 0.02 },
                ],
              }}
              activeDot={{
                r: 5,
                fill: color,
                stroke: "muted",
                strokeWidth: 2,
              }}
            />
          </AreaChart>
        </ChartContainer>
        <View className="flex-row flex-wrap gap-x-6">
          {stats.map(([term, value]) => (
            <View
              key={term}
              className="min-w-36 grow basis-0 flex-row items-center gap-2 border-b border-border py-[0.4375rem]"
            >
              <Text className="grow text-sm text-muted-foreground">{term}</Text>
              <Text className="text-sm font-semibold tabular-nums">
                {value}
              </Text>
            </View>
          ))}
        </View>
      </CardContent>
    </Card>
  )
}
```

4. Update the import paths to match your project.

**StyleSheet**

1. Add the pieces it builds on: [Card](https://ui.suiss.app/docs/components/card), [Chart](https://ui.suiss.app/docs/components/chart), [Text](https://ui.suiss.app/docs/components/text), [Toggle Group](https://ui.suiss.app/docs/components/toggle-group).

2. Copy the source into `components/charts/area-chart-interactive.tsx`:

```tsx title="stylesheet/components/charts/area-chart-interactive.tsx"
import * as React from "react"
import { StyleSheet, View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  Area,
  AreaChart,
  CartesianGrid,
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ReferenceLine,
  XAxis,
  YAxis,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { fontSize, palette, useTheme } from "@/components/ui/theme"
import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"

type Range = "1D" | "1W" | "1M" | "3M" | "1Y" | "5Y"

type Point = { index: number; price: number; label: string; tick: string }

const RANGES: Record<
  Range,
  {
    points: number
    seed: number
    volatility: number
    drift: number
    caption: string
  }
> = {
  "1D": {
    points: 78,
    seed: 11,
    volatility: 0.0025,
    drift: 0.46,
    caption: "Today",
  },
  "1W": {
    points: 35,
    seed: 23,
    volatility: 0.004,
    drift: 0.46,
    caption: "Past week",
  },
  "1M": {
    points: 22,
    seed: 37,
    volatility: 0.012,
    drift: 0.53,
    caption: "Past month",
  },
  "3M": {
    points: 64,
    seed: 41,
    volatility: 0.014,
    drift: 0.46,
    caption: "Past 3 months",
  },
  "1Y": {
    points: 52,
    seed: 59,
    volatility: 0.03,
    drift: 0.46,
    caption: "Past year",
  },
  "5Y": {
    points: 60,
    seed: 73,
    volatility: 0.045,
    drift: 0.46,
    caption: "Past 5 years",
  },
}

const LAST_PRICE = 254.32
const END = new Date(2026, 9, 6)
const SAMPLES = 80

const MONTHS = [
  "January",
  "February",
  "March",
  "April",
  "May",
  "June",
  "July",
  "August",
  "September",
  "October",
  "November",
  "December",
]

const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]

function shortMonth(date: Date) {
  return (MONTHS[date.getMonth()] ?? "").slice(0, 3)
}

function shortDay(date: Date) {
  return `${shortMonth(date)} ${date.getDate()}`
}

const styles = StyleSheet.create({
  header: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "flex-start",
    justifyContent: "space-between",
    gap: 16,
  },
  heading: { minWidth: 224, flexGrow: 1, gap: 2 },
  ticker: { flexDirection: "row", alignItems: "baseline", gap: 8 },
  symbol: { ...fontSize["2xl"], fontWeight: "600", letterSpacing: -0.6 },
  company: { ...fontSize.base },
  quote: {
    flexDirection: "row",
    flexWrap: "wrap",
    alignItems: "baseline",
    gap: 10,
  },
  price: {
    ...fontSize["3xl"],
    fontWeight: "600",
    letterSpacing: -0.75,
    fontVariant: ["tabular-nums"],
  },
  change: {
    ...fontSize.base,
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
  caption: { ...fontSize.sm, minHeight: 20 },
  content: { gap: 16 },
  chart: { height: 288 },
  stats: { flexDirection: "row", flexWrap: "wrap", columnGap: 24 },
  stat: {
    minWidth: 144,
    flexGrow: 1,
    flexBasis: 0,
    flexDirection: "row",
    alignItems: "center",
    gap: 8,
    borderBottomWidth: 1,
    paddingVertical: 7,
  },
  term: { ...fontSize.sm, flexGrow: 1 },
  amount: { ...fontSize.sm, fontWeight: "600", fontVariant: ["tabular-nums"] },
})

function formatPrice(value: number, signed = false) {
  const sign = signed && value >= 0 ? "+" : ""
  try {
    return `${sign}${new Intl.NumberFormat("en-US", {
      minimumFractionDigits: 2,
      maximumFractionDigits: 2,
    }).format(value)}`
  } catch {
    const [whole = "0", fraction = "00"] = value.toFixed(2).split(".")
    return `${sign}${whole.replace(/\B(?=(\d{3})+(?!\d))/g, ",")}.${fraction}`
  }
}

function businessDays(count: number) {
  const days: Date[] = []
  const day = new Date(END)
  while (days.length < count) {
    if (day.getDay() % 6) days.unshift(new Date(day))
    day.setDate(day.getDate() - 1)
  }
  return days
}

function generate(range: Range): Point[] {
  const { points, seed, volatility, drift } = RANGES[range]
  let state = seed
  let value = 100
  const values = Array.from({ length: points }, () => {
    state = (state * 16807) % 2147483647
    value *= 1 + ((state - 1) / 2147483646 - drift) * volatility
    return value
  })
  const last = values.at(-1)
  if (last === undefined) return []
  const scale = LAST_PRICE / last
  const days = businessDays(points)
  const week = businessDays(5)

  return values.map((raw, index) => {
    const price = Number((raw * scale).toFixed(2))
    if (range === "1D") {
      const minutes = 570 + index * 5
      const hour = Math.floor(minutes / 60)
      const minute = minutes % 60
      const time = `${hour}:${String(minute).padStart(2, "0")}`
      return {
        index,
        price,
        label: time,
        tick: minute === 0 ? `${hour}:00` : "",
      }
    }
    if (range === "1W") {
      const day = week[Math.floor(index / 7)] ?? END
      const weekday = WEEKDAYS[day.getDay()] ?? ""
      return {
        index,
        price,
        label: `${weekday} ${10 + (index % 7)}:00`,
        tick: index % 7 === 0 ? weekday : "",
      }
    }
    if (range === "1M" || range === "3M") {
      const label = shortDay(days[index] ?? END)
      return { index, price, label, tick: label }
    }
    if (range === "1Y") {
      const day = new Date(END)
      day.setDate(day.getDate() - (points - 1 - index) * 7)
      return {
        index,
        price,
        label: `${shortDay(day)}, ${day.getFullYear()}`,
        tick: shortMonth(day),
      }
    }
    const month = new Date(
      END.getFullYear(),
      END.getMonth() - (points - 1 - index),
      1
    )
    return {
      index,
      price,
      label: `${MONTHS[month.getMonth()]} ${month.getFullYear()}`,
      tick: String(month.getFullYear()),
    }
  })
}

function resample(source: Point[]) {
  const samples: Point[] = []
  let previous = -1
  for (let index = 0; index < SAMPLES; index++) {
    const position = (index * (source.length - 1)) / (SAMPLES - 1)
    const from = Math.floor(position)
    const to = Math.min(source.length - 1, from + 1)
    const nearest = Math.round(position)
    const start = source[from]
    const end = source[to]
    const closest = source[nearest]
    if (!start || !end || !closest) return samples
    const price = start.price + (end.price - start.price) * (position - from)
    samples.push({
      index,
      price: Number(price.toFixed(2)),
      label: closest.label,
      tick: nearest !== previous ? closest.tick : "",
    })
    previous = nearest
  }
  return samples
}

function tickIndexes(points: Point[], max: number) {
  const ticks = points
    .filter(
      (point, index) => point.tick && point.tick !== points[index - 1]?.tick
    )
    .map((point) => point.index)
  if (ticks.length <= max) return ticks
  const step = Math.ceil(ticks.length / max)
  return ticks.filter((_, index) => index % step === 0)
}

export function AreaChartInteractive() {
  const { colors, isDark } = useTheme()
  const success = isDark ? palette.green400 : palette.green600
  const [range, setRange] = React.useState<Range>("1M")
  const [active, setActive] = React.useState<number | null>(null)
  const points = React.useMemo(() => resample(generate(range)), [range])

  const opening = points[0]
  const current = points[active ?? points.length - 1]
  if (!opening || !current) return null

  const first = opening.price
  const change = current.price - first
  const rising = change >= 0
  const prices = points.map((point) => point.price)
  const min = Math.min(...prices)
  const max = Math.max(...prices)
  const padding = (max - min) * 0.12
  const color = rising ? "green-500" : "destructive"
  const chartConfig = {
    price: { label: "Price", color },
  } satisfies ChartConfig

  const stats = [
    ["Open", formatPrice(first)],
    ["High", formatPrice(max)],
    ["Low", formatPrice(min)],
    ["Volume", range === "1D" ? "41.2M" : "1.24B"],
  ]

  return (
    <Card variant="filled" size="sm">
      <CardHeader style={styles.header}>
        <View style={styles.heading}>
          <View style={styles.ticker}>
            <Text style={styles.symbol}>ACME</Text>
            <Text style={[styles.company, { color: colors.mutedForeground }]}>
              Acme Corp.
            </Text>
          </View>
          <View style={styles.quote}>
            <Text style={styles.price}>${formatPrice(current.price)}</Text>
            <Text
              style={[
                styles.change,
                {
                  color: rising ? success : colors.destructive,
                },
              ]}
            >
              {formatPrice(change, true)} (
              {formatPrice(Math.abs((change / first) * 100))}%)
            </Text>
          </View>
          <Text style={[styles.caption, { color: colors.mutedForeground }]}>
            {active === null ? RANGES[range].caption : current.label}
          </Text>
        </View>
        <ToggleGroup
          spacing={0}
          value={[range]}
          onValueChange={(value) => {
            if (value[0]) setRange(value[0] as Range)
          }}
          accessibilityLabel="Time range"
        >
          {(Object.keys(RANGES) as Range[]).map((key) => (
            <ToggleGroupItem key={key} value={key} size="sm">
              <Text>{key}</Text>
            </ToggleGroupItem>
          ))}
        </ToggleGroup>
      </CardHeader>
      <CardContent style={styles.content}>
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="ACME price"
          style={styles.chart}
        >
          <AreaChart
            data={points}
            margin={{ top: 10, right: 0, left: 0, bottom: 0 }}
            onActiveIndexChange={setActive}
          >
            <CartesianGrid vertical={false} strokeDasharray="2 4" />
            <XAxis
              dataKey="index"
              type="number"
              domain={["dataMin", "dataMax"]}
              ticks={tickIndexes(points, 6)}
              tickFormatter={(index: number) => points[index]?.tick ?? ""}
              tickMargin={8}
              minTickGap={4}
            />
            <YAxis
              orientation="right"
              domain={[min - padding, max + padding]}
              tickFormatter={(value: number) => Math.round(value).toString()}
              tickMargin={6}
              tickCount={5}
              width={40}
            />
            <ReferenceLine
              y={first}
              stroke="muted-foreground"
              strokeDasharray="2 3"
            />
            <ChartTooltip
              content={() => null}
              cursor={{ stroke: "muted-foreground", strokeWidth: 1 }}
            />
            <Area
              isAnimationActive={false}
              dataKey="price"
              type="linear"
              strokeWidth={2}
              fill={{
                stops: [
                  { offset: 0, opacity: 0.3 },
                  { offset: 1, opacity: 0.02 },
                ],
              }}
              activeDot={{
                r: 5,
                fill: color,
                stroke: "muted",
                strokeWidth: 2,
              }}
            />
          </AreaChart>
        </ChartContainer>
        <View style={styles.stats}>
          {stats.map(([term, value]) => (
            <View
              key={term}
              style={[styles.stat, { borderColor: colors.border }]}
            >
              <Text style={[styles.term, { color: colors.mutedForeground }]}>
                {term}
              </Text>
              <Text style={styles.amount}>{value}</Text>
            </View>
          ))}
        </View>
      </CardContent>
    </Card>
  )
}
```

3. Update the import paths to match your project.

**SwiftUI**

1. Target iOS 17 or later.

2. Add the Swift package to your app:

```swift title="Package.swift"
.package(url: "https://github.com/phosphor-icons/swift", from: "2.1.0")
```

3. Copy the theme files every view uses:

- `swift/Theme/Theme.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Theme.swift)

- `swift/Theme/Tokens.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Theme/Tokens.swift)

4. Copy the source and every file it uses:

- `swift/Charts/AreaChartInteractive.swift`: shown above

- `swift/Components/Button.swift`: see [Button](https://ui.suiss.app/docs/components/button.md)

- `swift/Components/ButtonGroup.swift`: see [Button Group](https://ui.suiss.app/docs/components/button-group.md)

- `swift/Components/Card.swift`: see [Card](https://ui.suiss.app/docs/components/card.md)

- `swift/Components/Chart.swift`: see [Chart](https://ui.suiss.app/docs/components/chart.md)

- `swift/Components/Checkbox.swift`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `swift/Components/Field.swift`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `swift/Components/Haptics.swift`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `swift/Components/Item.swift`: see [Item](https://ui.suiss.app/docs/components/item.md)

- `swift/Components/Separator.swift`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `swift/Components/TextVariant.swift`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/swiftui/Sources/EsuissUI/Components/TextVariant.swift)

- `swift/Components/Toggle.swift`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `swift/Components/ToggleGroup.swift`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

5. Add the files to your app target.

**Jetpack Compose**

1. Set `minSdk` to 24 or higher and add the dependencies:

```kotlin title="build.gradle.kts"
implementation(platform("androidx.compose:compose-bom:2026.09.00"))
implementation("androidx.compose.foundation:foundation")
implementation("androidx.compose.ui:ui")
implementation("com.adamglin:phosphor-icon-android:1.0.0")
```

2. Copy the theme files every view uses:

- `kotlin/theme/Theme.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Theme.kt)

- `kotlin/theme/Tokens.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/theme/Tokens.kt)

3. Copy the source and every file it uses:

- `kotlin/charts/AreaChartInteractive.kt`: shown above

- `kotlin/components/Card.kt`: see [Card](https://ui.suiss.app/docs/components/card.md)

- `kotlin/components/Chart.kt`: see [Chart](https://ui.suiss.app/docs/components/chart.md)

- `kotlin/components/Checkbox.kt`: see [Checkbox](https://ui.suiss.app/docs/components/checkbox.md)

- `kotlin/components/Content.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/Content.kt)

- `kotlin/components/Field.kt`: see [Field](https://ui.suiss.app/docs/components/field.md)

- `kotlin/components/FormSupport.kt`: [source](https://github.com/e-suiss/ui/blob/main/packages/mobile/kotlin/ui/src/main/kotlin/com/esuiss/ui/components/FormSupport.kt)

- `kotlin/components/Haptics.kt`: see [Haptics](https://ui.suiss.app/docs/components/haptics.md)

- `kotlin/components/Icon.kt`: see [Icon](https://ui.suiss.app/docs/components/icon.md)

- `kotlin/components/Label.kt`: see [Label](https://ui.suiss.app/docs/components/label.md)

- `kotlin/components/Separator.kt`: see [Separator](https://ui.suiss.app/docs/components/separator.md)

- `kotlin/components/Toggle.kt`: see [Toggle](https://ui.suiss.app/docs/components/toggle.md)

- `kotlin/components/ToggleGroup.kt`: see [Toggle Group](https://ui.suiss.app/docs/components/toggle-group.md)

4. Update the `com.esuiss.ui` package names to match your project.

## Usage

```tsx
import { AreaChartInteractive } from "@/components/charts/area-chart-interactive"
```

```tsx
<AreaChartInteractive />
```

Replace `generate()` with your price history for each range, and set the symbol, company name and `LAST_PRICE`. Volume values are hard-coded in `stats`. The component takes no props.

