# Radar Chart Interactive

A phone model comparison where toggle chips show up to three models, which grow in and out of the radar.

Radar 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/radar-chart-interactive

**React**

**Example**

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

import * as React from "react"
import {
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
  useActiveTooltipLabel,
} from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { Toggle } from "@/components/ui/toggle"

type Model = "pro" | "standard" | "air" | "lite"

const AXES = ["Camera", "Battery", "Performance", "Display", "Weight", "Price"]

const SCORES: Record<Model, number[]> = {
  pro: [98, 90, 98, 95, 58, 52],
  standard: [85, 82, 88, 90, 74, 80],
  air: [78, 68, 92, 90, 98, 64],
  lite: [70, 80, 76, 72, 80, 95],
}

const MODELS = Object.keys(SCORES) as Model[]

const MAX_SELECTED = 3
const GROW_DURATION = 600

const chartConfig = {
  pro: { label: "Phone Pro", color: "var(--chart-4)" },
  standard: { label: "Phone", color: "var(--chart-1)" },
  air: { label: "Phone Air", color: "var(--color-cyan-500)" },
  lite: { label: "Phone Lite", color: "var(--color-purple-500)" },
} satisfies ChartConfig

const average = (model: Model) =>
  Math.round(SCORES[model].reduce((sum, score) => sum + score, 0) / AXES.length)

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

  React.useEffect(() => {
    const from = valuesRef.current
    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) / GROW_DURATION)
      const eased =
        progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2
      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 ActiveAxis({ onChange }: { onChange: (axis: string | null) => void }) {
  const label = useActiveTooltipLabel()
  const axis = typeof label === "string" ? label : null

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

  return null
}

export function RadarChartInteractive() {
  const [selected, setSelected] = React.useState<Model[]>(["pro", "air"])
  const [axis, setAxis] = React.useState<string | null>(null)
  const target = React.useMemo(
    () => MODELS.map((model) => (selected.includes(model) ? 1 : 0)),
    [selected]
  )
  const scale = useMorph(target)
  const axisIndex = axis === null ? -1 : AXES.indexOf(axis)
  const data = AXES.map((name, index) => ({
    axis: name,
    ...Object.fromEntries(
      MODELS.map((model, position) => [
        model,
        (SCORES[model][index] ?? 0) * (scale[position] ?? 0),
      ])
    ),
    ...Object.fromEntries(
      MODELS.map((model) => [`${model}Score`, SCORES[model][index]])
    ),
  }))

  const toggle = (model: Model, pressed: boolean) =>
    setSelected((current) => {
      if (!pressed)
        return current.length > 1
          ? current.filter((item) => item !== model)
          : current
      return current.length >= MAX_SELECTED
        ? [...current.slice(1), model]
        : [...current, model]
    })

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="gap-0.5">
        <p className="text-lg font-semibold">Compare phone models</p>
        <p className="text-sm text-muted-foreground">
          Up to three models · scores out of 100, sample data
        </p>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <div className="flex flex-wrap gap-2" role="group" aria-label="Models">
          {MODELS.map((model) => (
            <Toggle
              key={model}
              size="sm"
              pressed={selected.includes(model)}
              onPressedChange={(pressed) => toggle(model, pressed)}
              style={
                { "--chip": chartConfig[model].color } as React.CSSProperties
              }
              className="gap-1.5 bg-secondary font-semibold text-secondary-foreground aria-pressed:bg-[color-mix(in_oklab,var(--chip)_16%,transparent)] aria-pressed:text-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--chip)_22%,transparent)] aria-pressed:hover:text-foreground aria-pressed:active:bg-[color-mix(in_oklab,var(--chip)_28%,transparent)]"
            >
              <span
                aria-hidden
                className="size-2 rounded-full bg-border transition-colors in-aria-pressed:bg-(--chip)"
              />
              {chartConfig[model].label}
            </Toggle>
          ))}
        </div>
        <div className="flex flex-wrap items-center gap-6">
          <ChartContainer
            config={chartConfig}
            className="aspect-auto h-86 min-w-0 flex-[1_1_20rem]"
          >
            <RadarChart
              accessibilityLayer
              data={data}
              outerRadius="74%"
              margin={{ top: 8, right: 56, bottom: 8, left: 56 }}
            >
              <PolarGrid stroke="var(--border)" />
              <PolarAngleAxis
                dataKey="axis"
                tick={({ x, y, textAnchor, payload }) => (
                  <text
                    x={x}
                    y={y}
                    textAnchor={textAnchor}
                    dominantBaseline="central"
                    data-active={payload.value === axis ? "" : undefined}
                    className="fill-muted-foreground text-sm data-active:fill-foreground data-active:font-semibold"
                  >
                    {payload.value}
                  </text>
                )}
              />
              <PolarRadiusAxis
                domain={[0, 100]}
                tick={false}
                axisLine={false}
              />
              <ChartTooltip
                cursor={false}
                content={({ active, label, payload }) => (
                  <ChartTooltipContent
                    active={active}
                    label={label}
                    payload={payload?.filter((item) =>
                      selected.includes(item.dataKey as Model)
                    )}
                    indicator="line"
                    formatter={(_, name, item) => (
                      <div className="flex w-full items-center gap-2">
                        <span
                          className="h-3 w-0.75 shrink-0 rounded-full bg-(--swatch)"
                          style={
                            {
                              "--swatch": chartConfig[name as Model].color,
                            } as React.CSSProperties
                          }
                        />
                        <span className="flex-1 text-muted-foreground">
                          {chartConfig[name as Model].label}
                        </span>
                        <span className="font-semibold tabular-nums">
                          {item.payload[`${name}Score`]}
                        </span>
                      </div>
                    )}
                  />
                )}
              />
              {MODELS.map((model, position) => {
                const visibility = scale[position]
                if (visibility === undefined || visibility < 0.005) return null
                return (
                  <Radar
                    key={model}
                    dataKey={model}
                    stroke={`var(--color-${model})`}
                    strokeWidth={2}
                    strokeOpacity={Math.min(1, visibility * 1.5)}
                    fill={`var(--color-${model})`}
                    fillOpacity={0.14 * visibility}
                    dot={{
                      r: 3 * Math.min(1, visibility * 1.3),
                      fillOpacity: 1,
                      fill: `var(--color-${model})`,
                    }}
                    isAnimationActive={false}
                  />
                )
              })}
              <ActiveAxis onChange={setAxis} />
            </RadarChart>
          </ChartContainer>
          <ul
            className="flex min-w-56 flex-[1_1_16rem] flex-col sm:flex-[0_1_16rem]"
            aria-live="polite"
          >
            {MODELS.map((model) => {
              const shown = selected.includes(model)
              return (
                <li
                  key={model}
                  data-shown={shown ? "" : undefined}
                  inert={!shown}
                  className="grid scale-96 grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity,scale] duration-500 ease-[cubic-bezier(0.45,0,0.2,1)] data-shown:scale-100 data-shown:grid-rows-[1fr] data-shown:opacity-100"
                >
                  <div className="min-h-0 overflow-hidden">
                    <div className="mb-2.5 flex items-center gap-3 rounded-xl bg-background px-3.5 py-3">
                      <span
                        className="size-2.5 shrink-0 rounded-full bg-(--swatch)"
                        style={
                          {
                            "--swatch": chartConfig[model].color,
                          } as React.CSSProperties
                        }
                      />
                      <span className="flex flex-1 flex-col gap-0.5">
                        <span className="text-base font-semibold">
                          {chartConfig[model].label}
                        </span>
                        <span className="text-xs text-muted-foreground">
                          {axisIndex >= 0 ? axis : "Overall score"}
                        </span>
                      </span>
                      <span className="text-2xl font-semibold tabular-nums">
                        {axisIndex >= 0
                          ? SCORES[model][axisIndex]
                          : average(model)}
                      </span>
                    </div>
                  </div>
                </li>
              )
            })}
          </ul>
        </div>
      </CardContent>
    </Card>
  )
}
```

A phone comparison where chips select up to three models; their shapes grow into or shrink out of the radar, and side cards show each model's overall score or the score on the hovered axis. Use it as a template for comparing a handful of items across shared criteria.

## Installation

### CLI

```bash title="Terminal"
npx shadcn@latest add @esuiss/radar-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 radar-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](https://ui.suiss.app/docs/components/toggle).

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

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

import * as React from "react"
import {
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
  useActiveTooltipLabel,
} from "recharts"

import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
} from "@/components/ui/chart"
import { Toggle } from "@/components/ui/toggle"

type Model = "pro" | "standard" | "air" | "lite"

const AXES = ["Camera", "Battery", "Performance", "Display", "Weight", "Price"]

const SCORES: Record<Model, number[]> = {
  pro: [98, 90, 98, 95, 58, 52],
  standard: [85, 82, 88, 90, 74, 80],
  air: [78, 68, 92, 90, 98, 64],
  lite: [70, 80, 76, 72, 80, 95],
}

const MODELS = Object.keys(SCORES) as Model[]

const MAX_SELECTED = 3
const GROW_DURATION = 600

const chartConfig = {
  pro: { label: "Phone Pro", color: "var(--chart-4)" },
  standard: { label: "Phone", color: "var(--chart-1)" },
  air: { label: "Phone Air", color: "var(--color-cyan-500)" },
  lite: { label: "Phone Lite", color: "var(--color-purple-500)" },
} satisfies ChartConfig

const average = (model: Model) =>
  Math.round(SCORES[model].reduce((sum, score) => sum + score, 0) / AXES.length)

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

  React.useEffect(() => {
    const from = valuesRef.current
    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) / GROW_DURATION)
      const eased =
        progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2
      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 ActiveAxis({ onChange }: { onChange: (axis: string | null) => void }) {
  const label = useActiveTooltipLabel()
  const axis = typeof label === "string" ? label : null

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

  return null
}

export function RadarChartInteractive() {
  const [selected, setSelected] = React.useState<Model[]>(["pro", "air"])
  const [axis, setAxis] = React.useState<string | null>(null)
  const target = React.useMemo(
    () => MODELS.map((model) => (selected.includes(model) ? 1 : 0)),
    [selected]
  )
  const scale = useMorph(target)
  const axisIndex = axis === null ? -1 : AXES.indexOf(axis)
  const data = AXES.map((name, index) => ({
    axis: name,
    ...Object.fromEntries(
      MODELS.map((model, position) => [
        model,
        (SCORES[model][index] ?? 0) * (scale[position] ?? 0),
      ])
    ),
    ...Object.fromEntries(
      MODELS.map((model) => [`${model}Score`, SCORES[model][index]])
    ),
  }))

  const toggle = (model: Model, pressed: boolean) =>
    setSelected((current) => {
      if (!pressed)
        return current.length > 1
          ? current.filter((item) => item !== model)
          : current
      return current.length >= MAX_SELECTED
        ? [...current.slice(1), model]
        : [...current, model]
    })

  return (
    <Card variant="filled" size="sm" className="[--card-spacing:--spacing(5)]">
      <CardHeader className="gap-0.5">
        <p className="text-lg font-semibold">Compare phone models</p>
        <p className="text-sm text-muted-foreground">
          Up to three models · scores out of 100, sample data
        </p>
      </CardHeader>
      <CardContent className="flex flex-col gap-4">
        <div className="flex flex-wrap gap-2" role="group" aria-label="Models">
          {MODELS.map((model) => (
            <Toggle
              key={model}
              size="sm"
              pressed={selected.includes(model)}
              onPressedChange={(pressed) => toggle(model, pressed)}
              style={
                { "--chip": chartConfig[model].color } as React.CSSProperties
              }
              className="gap-1.5 bg-secondary font-semibold text-secondary-foreground aria-pressed:bg-[color-mix(in_oklab,var(--chip)_16%,transparent)] aria-pressed:text-foreground aria-pressed:hover:bg-[color-mix(in_oklab,var(--chip)_22%,transparent)] aria-pressed:hover:text-foreground aria-pressed:active:bg-[color-mix(in_oklab,var(--chip)_28%,transparent)]"
            >
              <span
                aria-hidden
                className="size-2 rounded-full bg-border transition-colors in-aria-pressed:bg-(--chip)"
              />
              {chartConfig[model].label}
            </Toggle>
          ))}
        </div>
        <div className="flex flex-wrap items-center gap-6">
          <ChartContainer
            config={chartConfig}
            className="aspect-auto h-86 min-w-0 flex-[1_1_20rem]"
          >
            <RadarChart
              accessibilityLayer
              data={data}
              outerRadius="74%"
              margin={{ top: 8, right: 56, bottom: 8, left: 56 }}
            >
              <PolarGrid stroke="var(--border)" />
              <PolarAngleAxis
                dataKey="axis"
                tick={({ x, y, textAnchor, payload }) => (
                  <text
                    x={x}
                    y={y}
                    textAnchor={textAnchor}
                    dominantBaseline="central"
                    data-active={payload.value === axis ? "" : undefined}
                    className="fill-muted-foreground text-sm data-active:fill-foreground data-active:font-semibold"
                  >
                    {payload.value}
                  </text>
                )}
              />
              <PolarRadiusAxis
                domain={[0, 100]}
                tick={false}
                axisLine={false}
              />
              <ChartTooltip
                cursor={false}
                content={({ active, label, payload }) => (
                  <ChartTooltipContent
                    active={active}
                    label={label}
                    payload={payload?.filter((item) =>
                      selected.includes(item.dataKey as Model)
                    )}
                    indicator="line"
                    formatter={(_, name, item) => (
                      <div className="flex w-full items-center gap-2">
                        <span
                          className="h-3 w-0.75 shrink-0 rounded-full bg-(--swatch)"
                          style={
                            {
                              "--swatch": chartConfig[name as Model].color,
                            } as React.CSSProperties
                          }
                        />
                        <span className="flex-1 text-muted-foreground">
                          {chartConfig[name as Model].label}
                        </span>
                        <span className="font-semibold tabular-nums">
                          {item.payload[`${name}Score`]}
                        </span>
                      </div>
                    )}
                  />
                )}
              />
              {MODELS.map((model, position) => {
                const visibility = scale[position]
                if (visibility === undefined || visibility < 0.005) return null
                return (
                  <Radar
                    key={model}
                    dataKey={model}
                    stroke={`var(--color-${model})`}
                    strokeWidth={2}
                    strokeOpacity={Math.min(1, visibility * 1.5)}
                    fill={`var(--color-${model})`}
                    fillOpacity={0.14 * visibility}
                    dot={{
                      r: 3 * Math.min(1, visibility * 1.3),
                      fillOpacity: 1,
                      fill: `var(--color-${model})`,
                    }}
                    isAnimationActive={false}
                  />
                )
              })}
              <ActiveAxis onChange={setAxis} />
            </RadarChart>
          </ChartContainer>
          <ul
            className="flex min-w-56 flex-[1_1_16rem] flex-col sm:flex-[0_1_16rem]"
            aria-live="polite"
          >
            {MODELS.map((model) => {
              const shown = selected.includes(model)
              return (
                <li
                  key={model}
                  data-shown={shown ? "" : undefined}
                  inert={!shown}
                  className="grid scale-96 grid-rows-[0fr] opacity-0 transition-[grid-template-rows,opacity,scale] duration-500 ease-[cubic-bezier(0.45,0,0.2,1)] data-shown:scale-100 data-shown:grid-rows-[1fr] data-shown:opacity-100"
                >
                  <div className="min-h-0 overflow-hidden">
                    <div className="mb-2.5 flex items-center gap-3 rounded-xl bg-background px-3.5 py-3">
                      <span
                        className="size-2.5 shrink-0 rounded-full bg-(--swatch)"
                        style={
                          {
                            "--swatch": chartConfig[model].color,
                          } as React.CSSProperties
                        }
                      />
                      <span className="flex flex-1 flex-col gap-0.5">
                        <span className="text-base font-semibold">
                          {chartConfig[model].label}
                        </span>
                        <span className="text-xs text-muted-foreground">
                          {axisIndex >= 0 ? axis : "Overall score"}
                        </span>
                      </span>
                      <span className="text-2xl font-semibold tabular-nums">
                        {axisIndex >= 0
                          ? SCORES[model][axisIndex]
                          : average(model)}
                      </span>
                    </div>
                  </div>
                </li>
              )
            })}
          </ul>
        </div>
      </CardContent>
    </Card>
  )
}
```

4. Update the import paths to match your project.

## Usage

```tsx
import { RadarChartInteractive } from "@/components/charts/radar-chart-interactive"
```

```tsx
<RadarChartInteractive />
```

Replace `AXES` with your criteria and `SCORES` with one array per item in the same order, then add each item's label and color to `chartConfig`. Change `MAX_SELECTED` to allow more or fewer items at once and the initial `selected` state to pick which ones start visible.

## Notes

The scale is fixed from 0 to 100, so rescale your scores or change the radius axis domain. Selecting past the limit drops the oldest selection, and the last model can't be turned off. The score cards are a live region, and the grow animation is skipped when reduced motion is preferred.

**React Native, SwiftUI, Jetpack Compose**

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

```tsx title="uniwind/components/charts/radar-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 {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { Toggle } from "@/components/ui/toggle"

type Model = "pro" | "standard" | "air" | "lite"

const AXES = ["Camera", "Battery", "Performance", "Display", "Weight", "Price"]

const SCORES: Record<Model, number[]> = {
  pro: [98, 90, 98, 95, 58, 52],
  standard: [85, 82, 88, 90, 74, 80],
  air: [78, 68, 92, 90, 98, 64],
  lite: [70, 80, 76, 72, 80, 95],
}

const MODELS = Object.keys(SCORES) as Model[]

const MAX_SELECTED = 3

const chartConfig = {
  pro: { label: "Phone Pro", color: "chart-4" },
  standard: { label: "Phone", color: "chart-1" },
  air: { label: "Phone Air", color: "cyan-500" },
  lite: { label: "Phone Lite", color: "purple-500" },
} satisfies ChartConfig

const SWATCH: Record<Model, { dot: string; chip: string }> = {
  pro: { dot: "bg-orange-500", chip: "bg-orange-500/15 active:opacity-80" },
  standard: { dot: "bg-blue-500", chip: "bg-blue-500/15 active:opacity-80" },
  air: { dot: "bg-cyan-500", chip: "bg-cyan-500/15 active:opacity-80" },
  lite: { dot: "bg-purple-500", chip: "bg-purple-500/15 active:opacity-80" },
}

const average = (model: Model) =>
  Math.round(SCORES[model].reduce((sum, score) => sum + score, 0) / AXES.length)

const data = AXES.map((name, index) => ({
  axis: name,
  ...Object.fromEntries(
    MODELS.map((model) => [model, SCORES[model][index] ?? 0])
  ),
}))

export function RadarChartInteractive() {
  const [selected, setSelected] = React.useState<Model[]>(["pro", "air"])
  const [axisIndex, setAxisIndex] = React.useState<number | null>(null)
  const axis = axisIndex === null ? undefined : AXES[axisIndex]

  const toggle = (model: Model, pressed: boolean) =>
    setSelected((current) => {
      if (!pressed)
        return current.length > 1
          ? current.filter((item) => item !== model)
          : current
      return current.length >= MAX_SELECTED
        ? [...current.slice(1), model]
        : [...current, model]
    })

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="gap-0.5">
        <Text className="text-lg font-semibold">Compare phone models</Text>
        <Text className="text-sm text-muted-foreground">
          Up to three models · scores out of 100, sample data
        </Text>
      </CardHeader>
      <CardContent className="gap-4">
        <View
          className="flex-row flex-wrap gap-2"
          role="group"
          accessibilityLabel="Models"
        >
          {MODELS.map((model) => (
            <Toggle
              key={model}
              size="sm"
              pressed={selected.includes(model)}
              onPressedChange={(pressed) => toggle(model, pressed)}
              className={(state) =>
                cn(
                  "gap-1.5",
                  state.pressed
                    ? SWATCH[model].chip
                    : "bg-secondary active:opacity-80"
                )
              }
            >
              <View
                className={cn(
                  "size-2 rounded-full",
                  selected.includes(model) ? SWATCH[model].dot : "bg-border"
                )}
              />
              <Text className="text-sm font-semibold text-foreground">
                {chartConfig[model].label}
              </Text>
            </Toggle>
          ))}
        </View>
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="Compare phone models"
          className="h-72 w-full"
        >
          <RadarChart
            data={data}
            outerRadius="74%"
            margin={{ top: 8, right: 56, bottom: 8, left: 56 }}
            onActiveIndexChange={setAxisIndex}
          >
            <PolarGrid />
            <PolarAngleAxis
              dataKey="axis"
              tickClassName={(_, index) =>
                cn(
                  "text-sm",
                  index === axisIndex && "font-semibold text-foreground"
                )
              }
            />
            <PolarRadiusAxis domain={[0, 100]} tick={false} />
            <ChartTooltip
              content={
                <ChartTooltipContent
                  indicator="line"
                  formatter={(value, name) => (
                    <View className="w-full flex-row items-center gap-2">
                      <View
                        className={cn(
                          "h-3 w-0.75 shrink-0 rounded-full",
                          SWATCH[name as Model].dot
                        )}
                      />
                      <Text className="grow text-muted-foreground">
                        {chartConfig[name as Model].label}
                      </Text>
                      <Text className="font-semibold tabular-nums">
                        {String(value)}
                      </Text>
                    </View>
                  )}
                />
              }
            />
            {MODELS.filter((model) => selected.includes(model)).map((model) => (
              <Radar
                isAnimationActive={false}
                key={model}
                dataKey={model}
                strokeWidth={2}
                fillOpacity={0.14}
                dot={{ r: 3 }}
              />
            ))}
          </RadarChart>
        </ChartContainer>
        <View className="gap-2.5" accessibilityLiveRegion="polite">
          {MODELS.filter((model) => selected.includes(model)).map((model) => (
            <View
              key={model}
              className="flex-row items-center gap-3 rounded-xl bg-background px-3.5 py-3"
            >
              <View
                className={cn(
                  "size-2.5 shrink-0 rounded-full",
                  SWATCH[model].dot
                )}
              />
              <View className="grow gap-0.5">
                <Text className="text-base font-semibold">
                  {chartConfig[model].label}
                </Text>
                <Text className="text-xs text-muted-foreground">
                  {axis ?? "Overall score"}
                </Text>
              </View>
              <Text className="text-2xl font-semibold tabular-nums">
                {axisIndex === null ? average(model) : SCORES[model][axisIndex]}
              </Text>
            </View>
          ))}
        </View>
      </CardContent>
    </Card>
  )
}
```

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

private let axes = ["Camera", "Battery", "Performance", "Display", "Weight", "Price"]

private let models = ["pro", "standard", "air", "lite"]

private let scores: [String: [Double]] = [
  "pro": [98, 90, 98, 95, 58, 52],
  "standard": [85, 82, 88, 90, 74, 80],
  "air": [78, 68, 92, 90, 98, 64],
  "lite": [70, 80, 76, 72, 80, 95],
]

private let maxSelected = 3

private let chartConfig: ChartConfig = [
  "pro": .init("Phone Pro", color: EsuissColor.chart4),
  "standard": .init("Phone", color: EsuissColor.chart1),
  "air": .init("Phone Air", color: Palette.cyan500),
  "lite": .init("Phone Lite", color: Palette.purple500),
]

private func average(_ model: String) -> Double {
  let values = scores[model] ?? []
  return (values.reduce(0, +) / Double(max(axes.count, 1))).rounded()
}

private struct ModelChipStyle: ToggleStyle {
  let color: Color

  func makeBody(configuration: Configuration) -> some View {
    HapticButton(style: PressReader { pressed in
      let shape = RoundedRectangle(cornerRadius: Radius.lg, style: .continuous)
      HStack(spacing: 6) {
        Circle()
          .fill(configuration.isOn ? color : EsuissColor.border)
          .frame(width: 8, height: 8)
        configuration.label
      }
      .scaledFont(.sm, weight: .semibold)
      .lineLimit(1)
      .foregroundStyle(EsuissColor.foreground)
      .padding(.horizontal, 12)
      .frame(minHeight: 32)
      .background(configuration.isOn ? color.opacity(0.16) : EsuissColor.secondary, in: shape)
      .opacity(pressed ? 0.8 : 1)
      .contentShape(shape)
    }) {
      configuration.isOn.toggle()
      return true
    }
    .accessibilityAddTraits(configuration.isOn ? .isSelected : [])
  }
}

public struct RadarChartInteractive: View {
  @State private var selected: [String]
  @State private var visible: Set<String>
  @State private var axis: Int?
  @Environment(\.accessibilityReduceMotion) private var reduceMotion

  public init() {
    _selected = State(initialValue: ["pro", "air"])
    _visible = State(initialValue: [])
  }

  init(selected: [String], axis: Int?) {
    _selected = State(initialValue: selected)
    _visible = State(initialValue: Set(selected))
    _axis = State(initialValue: axis)
  }

  public var body: some View {
    Card(.filled, size: .sm) {
      VStack(alignment: .leading, spacing: 2) {
        Text("Compare phone models")
          .textStyle(.lg, weight: .semibold)
        Text("Up to three models · scores out of 100, sample data")
          .textStyle(.sm)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .padding(.horizontal, 16)
      CardContent {
        VStack(alignment: .leading, spacing: 16) {
          ChartLegendLayout(horizontalSpacing: 8, verticalSpacing: 8, centered: false) {
            ForEach(models, id: \.self) { model in
              Toggle(isOn: Binding(get: { selected.contains(model) }, set: { toggle(model, pressed: $0) })) {
                Text(LocalizedStringKey(chartConfig.label(model)))
              }
              .toggleStyle(ModelChipStyle(color: chartConfig.color(model)))
            }
          }
          .accessibilityElement(children: .contain)
          .accessibilityLabel("Models")
          ChartContainer(config: chartConfig) {
            RadarChart(
              models.map { model in
                let shown: Double = visible.contains(model) ? 1 : 0
                return Radar(
                  model,
                  values: (scores[model] ?? []).map { $0 * shown },
                  fillOpacity: 0.14 * shown,
                  strokeWidth: 2,
                  strokeOpacity: shown,
                  dot: 3 * shown
                )
              },
              labels: axes,
              domain: 0...100,
              outerRadius: 0.74,
              margin: EdgeInsets(top: 8, leading: 56, bottom: 8, trailing: 56),
              angleAxis: PolarAngleAxis(textStyle: .sm, highlightsSelection: true),
              selection: $axis,
              accessibilityLabel: "Compare phone models",
              items: { index in
                models.filter(selected.contains).map { model in
                  ChartTooltipItem(model, value: scores[model]?[index] ?? 0)
                }
              },
              isAnimationActive: false
            ) { payload in
              ChartTooltipContent(payload, indicator: .line) { item in
                Capsule()
                  .fill(chartConfig.color(item.key))
                  .frame(width: 3, height: 12)
                Text(LocalizedStringKey(chartConfig.label(item.key)))
                  .foregroundStyle(EsuissColor.mutedForeground)
                  .frame(maxWidth: .infinity, alignment: .leading)
                Text(item.value, format: .number)
                  .scaledFont(TextStyle.xs, weight: .semibold)
                  .monospacedDigit()
              }
            }
          }
          .frame(height: 344)
          VStack(spacing: 10) {
            ForEach(models.filter(selected.contains), id: \.self) { model in
              row(model)
                .transition(.opacity.combined(with: .scale(scale: 0.96)))
            }
          }
          .animation(reduceMotion ? nil : .timingCurve(0.45, 0, 0.2, 1, duration: 0.5), value: selected)
        }
      }
    }
    .onAppear { sync() }
    .onChange(of: selected) { _, _ in sync() }
  }

  private func sync() {
    let target = Set(selected)
    guard visible != target else { return }
    withAnimation(reduceMotion ? nil : .timingCurve(0.65, 0, 0.35, 1, duration: 0.6)) {
      visible = target
    }
  }

  private func toggle(_ model: String, pressed: Bool) {
    if !pressed {
      if selected.count > 1 { selected.removeAll { $0 == model } }
    } else if !selected.contains(model) {
      selected = selected.count >= maxSelected ? Array(selected.dropFirst()) + [model] : selected + [model]
    }
  }

  private func row(_ model: String) -> some View {
    let index = axis.flatMap { axes.indices.contains($0) ? $0 : nil }
    let value = index.map { scores[model]?[$0] ?? 0 } ?? average(model)
    return HStack(spacing: 12) {
      Circle()
        .fill(chartConfig.color(model))
        .frame(width: 10, height: 10)
      VStack(alignment: .leading, spacing: 2) {
        Text(LocalizedStringKey(chartConfig.label(model)))
          .textStyle(.base, weight: .semibold)
        Text(LocalizedStringKey(index.map { axes[$0] } ?? "Overall score"))
          .textStyle(.xs)
          .foregroundStyle(EsuissColor.mutedForeground)
      }
      .frame(maxWidth: .infinity, alignment: .leading)
      Text(value, format: .number)
        .textStyle(.xl2, weight: .semibold)
        .monospacedDigit()
    }
    .padding(.horizontal, 14)
    .padding(.vertical, 12)
    .background(EsuissColor.background, in: RoundedRectangle(cornerRadius: Radius.xl, style: .continuous))
    .accessibilityElement(children: .combine)
  }
}
```

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

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.CubicBezierEasing
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.scaleIn
import androidx.compose.animation.scaleOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.FlowRow
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
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.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.LiveRegionMode
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.liveRegion
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
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.ChartConfig
import com.esuiss.ui.components.ChartConfigItem
import com.esuiss.ui.components.ChartContainer
import com.esuiss.ui.components.ChartDot
import com.esuiss.ui.components.ChartMargin
import com.esuiss.ui.components.ChartTooltipContent
import com.esuiss.ui.components.ChartTooltipIndicator
import com.esuiss.ui.components.RadarChart
import com.esuiss.ui.components.chartColor
import com.esuiss.ui.components.formReducedMotion
import com.esuiss.ui.components.rememberHaptics
import com.esuiss.ui.theme.EsuissTheme
import com.esuiss.ui.theme.Palette
import com.esuiss.ui.theme.Radius
import com.esuiss.ui.theme.Typography
import kotlinx.coroutines.launch
import kotlin.math.min
import kotlin.math.roundToInt

private val axes = listOf("Camera", "Battery", "Performance", "Display", "Weight", "Price")

private val scores = linkedMapOf(
    "pro" to listOf(98, 90, 98, 95, 58, 52),
    "standard" to listOf(85, 82, 88, 90, 74, 80),
    "air" to listOf(78, 68, 92, 90, 98, 64),
    "lite" to listOf(70, 80, 76, 72, 80, 95),
)

private val models = scores.keys.toList()

private const val MAX_SELECTED = 3

private val chartConfig = ChartConfig(
    "pro" to ChartConfigItem("Phone Pro") { chart4 },
    "standard" to ChartConfigItem("Phone") { chart1 },
    "air" to ChartConfigItem("Phone Air") { Palette.cyan500 },
    "lite" to ChartConfigItem("Phone Lite") { Palette.purple500 },
)

private val MorphEasing = CubicBezierEasing(0.65f, 0f, 0.35f, 1f)

private val RevealEasing = CubicBezierEasing(0.45f, 0f, 0.2f, 1f)

private class RadarInteractiveRow(val axis: String, val values: Map<String, Double>, val scores: Map<String, Int>)

private fun average(model: String): Int = (scores.getValue(model).sum().toDouble() / axes.size).roundToInt()

@Composable
private fun ModelToggle(label: String, color: Color, pressed: Boolean, onPressedChange: (Boolean) -> Unit) {
    val colors = EsuissTheme.colors
    val haptics = rememberHaptics()
    val background by animateColorAsState(if (pressed) color.copy(alpha = 0.16f) else colors.secondary, label = "background")
    val dot by animateColorAsState(if (pressed) color else colors.border, label = "dot")
    Row(
        modifier = Modifier
            .height(32.dp)
            .background(background, RoundedCornerShape(Radius.lg))
            .toggleable(pressed, role = Role.Checkbox) {
                haptics.toggle(it)
                onPressedChange(it)
            }
            .padding(horizontal = 12.dp),
        horizontalArrangement = Arrangement.spacedBy(6.dp),
        verticalAlignment = Alignment.CenterVertically,
    ) {
        Box(
            Modifier
                .size(8.dp)
                .background(dot, CircleShape),
        )
        BasicText(label, style = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
    }
}

@Composable
fun RadarChartInteractive(modifier: Modifier = Modifier) {
    val colors = EsuissTheme.colors
    val reduced = formReducedMotion()
    var selected by remember { mutableStateOf(listOf("pro", "air")) }
    var axis by remember { mutableStateOf<Int?>(null) }
    val scale = remember { models.map { Animatable(0f) } }
    LaunchedEffect(selected) {
        models.forEachIndexed { index, model ->
            val target = if (model in selected) 1f else 0f
            launch {
                if (reduced) scale[index].snapTo(target) else scale[index].animateTo(target, tween(600, easing = MorphEasing))
            }
        }
    }
    val visibility = scale.map { it.value }
    val data = axes.mapIndexed { index, name ->
        RadarInteractiveRow(
            name,
            models.withIndex().associate { (position, model) -> model to scores.getValue(model)[index] * visibility[position].toDouble() },
            models.associateWith { scores.getValue(it)[index] },
        )
    }
    fun toggle(model: String, pressed: Boolean) {
        selected = when {
            !pressed -> if (selected.size > 1) selected - model else selected
            selected.size >= MAX_SELECTED -> selected.drop(1) + model
            else -> selected + model
        }
    }
    Card(modifier, variant = CardVariant.Filled, size = CardSize.Sm) {
        CardContent {
            Column(verticalArrangement = Arrangement.spacedBy(2.dp)) {
                BasicText("Compare phone models", style = Typography.lg.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold))
                BasicText(
                    "Up to three models · scores out of 100, sample data",
                    style = Typography.sm.copy(color = colors.mutedForeground),
                )
            }
        }
        CardContent(Modifier.padding(top = 20.dp)) {
            Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
                ChartContainer(chartConfig) {
                    FlowRow(
                        Modifier.semantics { contentDescription = "Models" },
                        horizontalArrangement = Arrangement.spacedBy(8.dp),
                        verticalArrangement = Arrangement.spacedBy(8.dp),
                    ) {
                        for (model in models) {
                            ModelToggle(chartConfig[model]?.label ?: model, chartColor(model), model in selected) { toggle(model, it) }
                        }
                    }
                }
                BoxWithConstraints(Modifier.fillMaxWidth()) {
                    val wide = maxWidth >= 568.dp
                    val chart = @Composable { chartModifier: Modifier ->
                        ChartContainer(chartConfig, chartModifier.height(344.dp)) {
                            RadarChart(
                                data,
                                margin = ChartMargin(top = 8.dp, right = 56.dp, bottom = 8.dp, left = 56.dp),
                                outerRadius = 0.74f,
                                accessibilityLabel = "Compare phone models",
                                selectedIndex = axis,
                                onSelectedIndexChange = { axis = it },
                            ) {
                                PolarGrid(stroke = colors.border)
                                PolarAngleAxis(
                                    label = { it.axis },
                                    tickStyle = Typography.sm.copy(color = colors.mutedForeground),
                                    activeTickStyle = Typography.sm.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                                )
                                PolarRadiusAxis(domain = 0.0..100.0, tick = false)
                                ChartTooltip {
                                    ChartTooltipContent(
                                        indicator = ChartTooltipIndicator.Line,
                                        payload = items.filter { it.dataKey in selected },
                                        formatter = { item ->
                                            val row = item.payload as RadarInteractiveRow
                                            Row(
                                                Modifier.fillMaxWidth(),
                                                horizontalArrangement = Arrangement.spacedBy(8.dp),
                                                verticalAlignment = Alignment.CenterVertically,
                                            ) {
                                                Box(
                                                    Modifier
                                                        .width(3.dp)
                                                        .height(12.dp)
                                                        .background(item.color, CircleShape),
                                                )
                                                BasicText(
                                                    chartConfig[item.key]?.label ?: item.key,
                                                    Modifier.weight(1f),
                                                    style = Typography.xs.copy(color = colors.mutedForeground),
                                                )
                                                BasicText(
                                                    row.scores.getValue(item.key).toString(),
                                                    style = Typography.xs.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                                                )
                                            }
                                        },
                                    )
                                }
                                models.forEachIndexed { position, model ->
                                    val shown = visibility[position]
                                    if (shown >= 0.005f) {
                                        Radar(
                                            model,
                                            value = { it.values.getValue(model) },
                                            strokeWidth = 2.dp,
                                            strokeOpacity = min(1f, shown * 1.5f),
                                            fillOpacity = 0.14f * shown,
                                            dot = ChartDot((3f * min(1f, shown * 1.3f)).dp, strokeWidth = 2.dp),
                                            isAnimationActive = false,
                                        )
                                    }
                                }
                            }
                        }
                    }
                    if (wide) {
                        Row(horizontalArrangement = Arrangement.spacedBy(24.dp), verticalAlignment = Alignment.CenterVertically) {
                            chart(Modifier.weight(1f))
                            ScoreList(selected, axis, reduced, Modifier.width(256.dp))
                        }
                    } else {
                        Column(verticalArrangement = Arrangement.spacedBy(24.dp)) {
                            chart(Modifier.fillMaxWidth())
                            ScoreList(selected, axis, reduced, Modifier.fillMaxWidth())
                        }
                    }
                }
            }
        }
    }
}

@Composable
private fun ScoreList(selected: List<String>, axis: Int?, reduced: Boolean, modifier: Modifier) {
    val colors = EsuissTheme.colors
    Column(modifier.semantics { liveRegion = LiveRegionMode.Polite }) {
        for (model in models) {
            AnimatedVisibility(
                visible = model in selected,
                enter = if (reduced) EnterTransition.None else expandVertically(tween(500, easing = RevealEasing)) + fadeIn(tween(500, easing = RevealEasing)) + scaleIn(tween(500, easing = RevealEasing), initialScale = 0.96f),
                exit = if (reduced) ExitTransition.None else shrinkVertically(tween(500, easing = RevealEasing)) + fadeOut(tween(500, easing = RevealEasing)) + scaleOut(tween(500, easing = RevealEasing), targetScale = 0.96f),
            ) {
                ChartContainer(chartConfig) {
                    Row(
                        Modifier
                            .padding(bottom = 10.dp)
                            .fillMaxWidth()
                            .background(colors.background, RoundedCornerShape(Radius.xl))
                            .semantics(mergeDescendants = true) {}
                            .padding(horizontal = 14.dp, vertical = 12.dp),
                        horizontalArrangement = Arrangement.spacedBy(12.dp),
                        verticalAlignment = Alignment.CenterVertically,
                    ) {
                        Box(
                            Modifier
                                .size(10.dp)
                                .background(chartColor(model), CircleShape),
                        )
                        Column(Modifier.weight(1f), verticalArrangement = Arrangement.spacedBy(2.dp)) {
                            BasicText(
                                chartConfig[model]?.label ?: model,
                                style = Typography.base.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold),
                            )
                            BasicText(
                                if (axis != null) axes[axis] else "Overall score",
                                style = Typography.xs.copy(color = colors.mutedForeground),
                            )
                        }
                        BasicText(
                            (if (axis != null) scores.getValue(model)[axis] else average(model)).toString(),
                            style = Typography.xl2.copy(color = colors.foreground, fontWeight = FontWeight.SemiBold, fontFeatureSettings = "tnum"),
                        )
                    }
                }
            }
        }
    }
}
```

A `Card` with a row of `Toggle` chips, a `RadarChart` and a summary card per model. Scores live in `SCORES`, one array per model, in the order of `AXES`. Up to three models can be selected; picking a fourth drops the oldest, and the last one cannot be cleared. Pressing an axis bolds its label and switches each summary from the overall average to that axis score; a tooltip lists the values. Each radar sets `isAnimationActive={false}` and draws without the enter animation.

## Installation

**React Native**

```bash title="Terminal"
npx shadcn@latest add @esuiss-rn/radar-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 radar-chart-interactive
```

**SwiftUI**

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

**Jetpack Compose**

```bash title="Terminal"
npx @esuiss/ui@latest add radar-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](https://ui.suiss.app/docs/components/toggle).

### 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](https://ui.suiss.app/docs/components/toggle).

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

- `uniwind/components/charts/radar-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](https://ui.suiss.app/docs/components/toggle).

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

```tsx title="nativewind/components/charts/radar-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 {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import { Toggle } from "@/components/ui/toggle"

type Model = "pro" | "standard" | "air" | "lite"

const AXES = ["Camera", "Battery", "Performance", "Display", "Weight", "Price"]

const SCORES: Record<Model, number[]> = {
  pro: [98, 90, 98, 95, 58, 52],
  standard: [85, 82, 88, 90, 74, 80],
  air: [78, 68, 92, 90, 98, 64],
  lite: [70, 80, 76, 72, 80, 95],
}

const MODELS = Object.keys(SCORES) as Model[]

const MAX_SELECTED = 3

const chartConfig = {
  pro: { label: "Phone Pro", color: "chart-4" },
  standard: { label: "Phone", color: "chart-1" },
  air: { label: "Phone Air", color: "cyan-500" },
  lite: { label: "Phone Lite", color: "purple-500" },
} satisfies ChartConfig

const SWATCH: Record<Model, { dot: string; chip: string }> = {
  pro: { dot: "bg-orange-500", chip: "bg-orange-500/15 active:opacity-80" },
  standard: { dot: "bg-blue-500", chip: "bg-blue-500/15 active:opacity-80" },
  air: { dot: "bg-cyan-500", chip: "bg-cyan-500/15 active:opacity-80" },
  lite: { dot: "bg-purple-500", chip: "bg-purple-500/15 active:opacity-80" },
}

const average = (model: Model) =>
  Math.round(SCORES[model].reduce((sum, score) => sum + score, 0) / AXES.length)

const data = AXES.map((name, index) => ({
  axis: name,
  ...Object.fromEntries(
    MODELS.map((model) => [model, SCORES[model][index] ?? 0])
  ),
}))

export function RadarChartInteractive() {
  const [selected, setSelected] = React.useState<Model[]>(["pro", "air"])
  const [axisIndex, setAxisIndex] = React.useState<number | null>(null)
  const axis = axisIndex === null ? undefined : AXES[axisIndex]

  const toggle = (model: Model, pressed: boolean) =>
    setSelected((current) => {
      if (!pressed)
        return current.length > 1
          ? current.filter((item) => item !== model)
          : current
      return current.length >= MAX_SELECTED
        ? [...current.slice(1), model]
        : [...current, model]
    })

  return (
    <Card variant="filled" size="sm">
      <CardHeader className="gap-0.5">
        <Text className="text-lg font-semibold">Compare phone models</Text>
        <Text className="text-sm text-muted-foreground">
          Up to three models · scores out of 100, sample data
        </Text>
      </CardHeader>
      <CardContent className="gap-4">
        <View
          className="flex-row flex-wrap gap-2"
          role="group"
          accessibilityLabel="Models"
        >
          {MODELS.map((model) => (
            <Toggle
              key={model}
              size="sm"
              pressed={selected.includes(model)}
              onPressedChange={(pressed) => toggle(model, pressed)}
              className={(state) =>
                cn(
                  "gap-1.5",
                  state.pressed
                    ? SWATCH[model].chip
                    : "bg-secondary active:opacity-80"
                )
              }
            >
              <View
                className={cn(
                  "size-2 rounded-full",
                  selected.includes(model) ? SWATCH[model].dot : "bg-border"
                )}
              />
              <Text className="text-sm font-semibold text-foreground">
                {chartConfig[model].label}
              </Text>
            </Toggle>
          ))}
        </View>
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="Compare phone models"
          className="h-72 w-full"
        >
          <RadarChart
            data={data}
            outerRadius="74%"
            margin={{ top: 8, right: 56, bottom: 8, left: 56 }}
            onActiveIndexChange={setAxisIndex}
          >
            <PolarGrid />
            <PolarAngleAxis
              dataKey="axis"
              tickClassName={(_, index) =>
                cn(
                  "text-sm",
                  index === axisIndex && "font-semibold text-foreground"
                )
              }
            />
            <PolarRadiusAxis domain={[0, 100]} tick={false} />
            <ChartTooltip
              content={
                <ChartTooltipContent
                  indicator="line"
                  formatter={(value, name) => (
                    <View className="w-full flex-row items-center gap-2">
                      <View
                        className={cn(
                          "h-3 w-[0.1875rem] shrink-0 rounded-full",
                          SWATCH[name as Model].dot
                        )}
                      />
                      <Text className="grow text-muted-foreground">
                        {chartConfig[name as Model].label}
                      </Text>
                      <Text className="font-semibold tabular-nums">
                        {String(value)}
                      </Text>
                    </View>
                  )}
                />
              }
            />
            {MODELS.filter((model) => selected.includes(model)).map((model) => (
              <Radar
                isAnimationActive={false}
                key={model}
                dataKey={model}
                strokeWidth={2}
                fillOpacity={0.14}
                dot={{ r: 3 }}
              />
            ))}
          </RadarChart>
        </ChartContainer>
        <View className="gap-2.5" accessibilityLiveRegion="polite">
          {MODELS.filter((model) => selected.includes(model)).map((model) => (
            <View
              key={model}
              className="flex-row items-center gap-3 rounded-xl bg-background px-3.5 py-3"
            >
              <View
                className={cn(
                  "size-2.5 shrink-0 rounded-full",
                  SWATCH[model].dot
                )}
              />
              <View className="grow gap-0.5">
                <Text className="text-base font-semibold">
                  {chartConfig[model].label}
                </Text>
                <Text className="text-xs text-muted-foreground">
                  {axis ?? "Overall score"}
                </Text>
              </View>
              <Text className="text-2xl font-semibold tabular-nums">
                {axisIndex === null ? average(model) : SCORES[model][axisIndex]}
              </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](https://ui.suiss.app/docs/components/toggle).

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

```tsx title="stylesheet/components/charts/radar-chart-interactive.tsx"
import * as React from "react"
import { StyleSheet, View } from "react-native"
import { Card, CardContent, CardHeader } from "@/components/ui/card"
import {
  type ChartConfig,
  ChartContainer,
  ChartTooltip,
  ChartTooltipContent,
  PolarAngleAxis,
  PolarGrid,
  PolarRadiusAxis,
  Radar,
  RadarChart,
} from "@/components/ui/chart"
import { Text } from "@/components/ui/text"
import {
  fontSize,
  palette,
  radius,
  type ThemeColors,
  useTheme,
} from "@/components/ui/theme"

import { Toggle } from "@/components/ui/toggle"

type Tone = keyof ThemeColors | keyof typeof palette

function tone(colors: ThemeColors, key: Tone) {
  return { ...colors, ...palette }[key]
}

type Model = "pro" | "standard" | "air" | "lite"

const AXES = ["Camera", "Battery", "Performance", "Display", "Weight", "Price"]

const SCORES: Record<Model, number[]> = {
  pro: [98, 90, 98, 95, 58, 52],
  standard: [85, 82, 88, 90, 74, 80],
  air: [78, 68, 92, 90, 98, 64],
  lite: [70, 80, 76, 72, 80, 95],
}

const MODELS = Object.keys(SCORES) as Model[]

const MAX_SELECTED = 3

const chartConfig = {
  pro: { label: "Phone Pro", color: "chart-4" },
  standard: { label: "Phone", color: "chart-1" },
  air: { label: "Phone Air", color: "cyan-500" },
  lite: { label: "Phone Lite", color: "purple-500" },
} satisfies ChartConfig

const SWATCH: Record<Model, Tone> = {
  pro: "chart4",
  standard: "chart1",
  air: "cyan500",
  lite: "purple500",
}

function withAlpha(color: string, alpha: number) {
  if (!color.startsWith("#") || color.length !== 7) return color
  const r = Number.parseInt(color.slice(1, 3), 16)
  const g = Number.parseInt(color.slice(3, 5), 16)
  const b = Number.parseInt(color.slice(5, 7), 16)
  return `rgba(${r}, ${g}, ${b}, ${alpha})`
}

const styles = StyleSheet.create({
  header: { gap: 2 },
  title: { ...fontSize.lg, fontWeight: "600" },
  caption: { ...fontSize.sm },
  content: { gap: 16 },
  models: { flexDirection: "row", flexWrap: "wrap", gap: 8 },
  model: { gap: 6 },
  dot: { width: 8, height: 8, borderRadius: 9999 },
  modelLabel: { ...fontSize.sm, fontWeight: "600" },
  chart: { height: 288 },
  tick: { ...fontSize.sm },
  activeTick: { ...fontSize.sm, fontWeight: "600" },
  row: { width: "100%", flexDirection: "row", alignItems: "center", gap: 8 },
  bar: { height: 12, width: 3, flexShrink: 0, borderRadius: 9999 },
  grow: { flexGrow: 1 },
  strong: { fontWeight: "600", fontVariant: ["tabular-nums"] },
  scores: { gap: 10 },
  score: {
    flexDirection: "row",
    alignItems: "center",
    gap: 12,
    borderRadius: radius.xl,
    paddingHorizontal: 14,
    paddingVertical: 12,
  },
  swatch: { width: 10, height: 10, flexShrink: 0, borderRadius: 9999 },
  scoreText: { flexGrow: 1, gap: 2 },
  scoreLabel: { ...fontSize.base, fontWeight: "600" },
  scoreCaption: { ...fontSize.xs },
  scoreValue: {
    ...fontSize["2xl"],
    fontWeight: "600",
    fontVariant: ["tabular-nums"],
  },
})

const average = (model: Model) =>
  Math.round(SCORES[model].reduce((sum, score) => sum + score, 0) / AXES.length)

const data = AXES.map((name, index) => ({
  axis: name,
  ...Object.fromEntries(
    MODELS.map((model) => [model, SCORES[model][index] ?? 0])
  ),
}))

export function RadarChartInteractive() {
  const { colors } = useTheme()
  const [selected, setSelected] = React.useState<Model[]>(["pro", "air"])
  const [axisIndex, setAxisIndex] = React.useState<number | null>(null)
  const axis = axisIndex === null ? undefined : AXES[axisIndex]

  const toggle = (model: Model, pressed: boolean) =>
    setSelected((current) => {
      if (!pressed)
        return current.length > 1
          ? current.filter((item) => item !== model)
          : current
      return current.length >= MAX_SELECTED
        ? [...current.slice(1), model]
        : [...current, model]
    })

  return (
    <Card variant="filled" size="sm">
      <CardHeader style={styles.header}>
        <Text style={styles.title}>Compare phone models</Text>
        <Text style={[styles.caption, { color: colors.mutedForeground }]}>
          Up to three models · scores out of 100, sample data
        </Text>
      </CardHeader>
      <CardContent style={styles.content}>
        <View style={styles.models} role="group" accessibilityLabel="Models">
          {MODELS.map((model) => (
            <Toggle
              key={model}
              size="sm"
              pressed={selected.includes(model)}
              onPressedChange={(pressed) => toggle(model, pressed)}
              style={(state) => [
                styles.model,
                {
                  backgroundColor: state.pressed
                    ? withAlpha(tone(colors, SWATCH[model]), 0.15)
                    : colors.secondary,
                },
              ]}
            >
              <View
                style={[
                  styles.dot,
                  {
                    backgroundColor: selected.includes(model)
                      ? tone(colors, SWATCH[model])
                      : colors.border,
                  },
                ]}
              />
              <Text style={[styles.modelLabel, { color: colors.foreground }]}>
                {chartConfig[model].label}
              </Text>
            </Toggle>
          ))}
        </View>
        <ChartContainer
          config={chartConfig}
          accessibilityLabel="Compare phone models"
          style={styles.chart}
        >
          <RadarChart
            data={data}
            outerRadius="74%"
            margin={{ top: 8, right: 56, bottom: 8, left: 56 }}
            onActiveIndexChange={setAxisIndex}
          >
            <PolarGrid />
            <PolarAngleAxis
              dataKey="axis"
              tickStyle={(_, index) =>
                index === axisIndex
                  ? [styles.activeTick, { color: colors.foreground }]
                  : styles.tick
              }
            />
            <PolarRadiusAxis domain={[0, 100]} tick={false} />
            <ChartTooltip
              content={
                <ChartTooltipContent
                  indicator="line"
                  formatter={(value, name) => (
                    <View style={styles.row}>
                      <View
                        style={[
                          styles.bar,
                          {
                            backgroundColor: tone(
                              colors,
                              SWATCH[name as Model]
                            ),
                          },
                        ]}
                      />
                      <Text
                        style={[styles.grow, { color: colors.mutedForeground }]}
                      >
                        {chartConfig[name as Model].label}
                      </Text>
                      <Text style={styles.strong}>{String(value)}</Text>
                    </View>
                  )}
                />
              }
            />
            {MODELS.filter((model) => selected.includes(model)).map((model) => (
              <Radar
                isAnimationActive={false}
                key={model}
                dataKey={model}
                strokeWidth={2}
                fillOpacity={0.14}
                dot={{ r: 3 }}
              />
            ))}
          </RadarChart>
        </ChartContainer>
        <View style={styles.scores} accessibilityLiveRegion="polite">
          {MODELS.filter((model) => selected.includes(model)).map((model) => (
            <View
              key={model}
              style={[styles.score, { backgroundColor: colors.background }]}
            >
              <View
                style={[
                  styles.swatch,
                  { backgroundColor: tone(colors, SWATCH[model]) },
                ]}
              />
              <View style={styles.scoreText}>
                <Text style={styles.scoreLabel}>
                  {chartConfig[model].label}
                </Text>
                <Text
                  style={[
                    styles.scoreCaption,
                    { color: colors.mutedForeground },
                  ]}
                >
                  {axis ?? "Overall score"}
                </Text>
              </View>
              <Text style={styles.scoreValue}>
                {axisIndex === null ? average(model) : SCORES[model][axisIndex]}
              </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/RadarChartInteractive.swift`: shown above

- `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)

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")
```

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/RadarChartInteractive.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/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/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)

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

## Usage

```tsx
import { RadarChartInteractive } from "@/components/charts/radar-chart-interactive"
```

```tsx
<RadarChartInteractive />
```

Replace `AXES` and `SCORES` with your attributes and items, and add a matching entry to `chartConfig` and `SWATCH` for each. Change `MAX_SELECTED` to allow more series. The component takes no props.

