# Radar Chart Interactive

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

A shadcn/ui block from the [@esuiss](https://suiss.app/ui?utm_source=shadcn.exchange) registry, listed on [shadcn.exchange](https://shadcn.exchange/r/esuiss/radar-chart-interactive).

- Page, with a live preview: https://shadcn.exchange/r/esuiss/radar-chart-interactive
- Screenshot: https://abzjhwgwpdpz7ua5.public.blob.vercel-storage.com/shots/esuiss/radar-chart-interactive-light-def52908c22c3776.png
- Registry item: https://suiss.app/ui/r/new-york-v4/radar-chart-interactive.json
- Categories: charts, charts-radar
- Listed: 2026-10-20, source last changed 2026-10-20

## Install

```bash
npx shadcn@latest add https://shadcn.exchange/r/esuiss/radar-chart-interactive.json
```

Run it in a project that has shadcn/ui set up. It adds the files below and installs what they depend on.

## Dependencies

- npm packages: `recharts`
- Registry items: `@esuiss/card`, `@esuiss/chart`, `@esuiss/toggle`

## Files

### `components/charts/radar-chart-interactive.tsx`

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

  React.useEffect(() => {
    const from = valuesRef.current
    const reduced = window.matchMedia(
      "(prefers-reduced-motion: reduce)"
    ).matches
    const start = performance.now()
    let frame = 0
    const step = () => {
      const progress = reduced
        ? 1
        : Math.min(1, (performance.now() - start) / GROW_DURATION)
      const eased =
        progress < 0.5 ? 4 * progress ** 3 : 1 - (-2 * progress + 2) ** 3 / 2
      const next =
        progress < 1
          ? target.map((value, index) => {
              const origin = from[index] ?? value
              return origin + (value - origin) * eased
            })
          : target
      valuesRef.current = next
      setValues(next)
      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>
  )
}
```
