shadimage
bjork-ui
componentanimated-radial-chartsource: ok · hit

Animated radial chart

Animated radial chart is a production-ready charts component from BJORK UI, packaged for shadcn registries.

npx shadcn add "https://ui.isaiahbjork.com/animated-radial-chart.json"
free

No selection tags yet — empty means no evidence, not lack of support.

Dependencies

Bundle weight · medium
animationframer-motion

Files2 · source cached 10/9/2026, 9:30:09 PM

components/bjork-ui/charts/animated-radial-chart.tsx
"use client"

import { motion, useMotionValue, useMotionValueEvent, useReducedMotion, useTransform, animate } from "framer-motion"
import { useEffect, useId, useState } from "react"
import { cn } from "@/lib/utils"

interface AnimatedRadialChartProps {
  value?: number
  size?: number
  strokeWidth?: number
  className?: string
  showLabels?: boolean
  duration?: number
  tone?: "light" | "dark"
}

export function AnimatedRadialChart({ 
  value = 74, 
  size = 300,
  strokeWidth: customStrokeWidth,
  className,
  showLabels = true,
  duration = 2,
  tone,
}: AnimatedRadialChartProps) {
  const shouldReduceMotion = useReducedMotion()
  const id = useId().replace(/:/g, "")
  const [displayValue, setDisplayValue] = useState(() =>
    duration <= 0 ? Math.round(value) : 0
  )
  const [isLight, setIsLight] = useState(false)
  const animationDuration = shouldReduceMotion ? 0.25 : duration
  // Dynamic stroke width based on size if not provided
  const strokeWidth = customStrokeWidth ?? Math.max(12, size * 0.06)
  const radius = size * 0.35
  const center = size / 2
  const circumference = Math.PI * radius

  // Calculate inner line radius (4px inside the main arc)
  const innerLineRadius = radius - strokeWidth - 4

  // Motion values for animation
  const animatedValue = useMotionValue(duration <= 0 ? value : 0)
  const offset = useTransform(animatedValue, [0, 100], [circumference, 0])

  useMotionValueEvent(animatedValue, "change", (latest) => {
    setDisplayValue(Math.round(latest))
  })

  useEffect(() => {
    if (tone) {
      setIsLight(tone === "light")
      return
    }

    const updateTheme = () => {
      setIsLight(document.documentElement.classList.contains("light"))
    }

    updateTheme()

    const observer = new MutationObserver(updateTheme)
    observer.observe(document.documentElement, {
      attributes: true,
      attributeFilter: ["class"],
    })

    return () => observer.disconnect()
  }, [tone])

  const isLightTone = tone ? tone === "light" : isLight

  // Calculate animated positions
  const progressAngle = useTransform(animatedValue, [0, 100], [-Math.PI, 0])
  const innerRadius = radius - strokeWidth / 2

  // Animate to the target value on mount or when value changes
  useEffect(() => {
    if (animationDuration <= 0) {
      animatedValue.set(value)
      setDisplayValue(Math.round(value))
      return
    }

    const controls = animate(animatedValue, value, {
      duration: animationDuration,
      ease: "easeOut",
    })

    return controls.stop
  }, [value, animatedValue, animationDuration])

  // Calculate responsive font size
  const fontSize = Math.max(16, size * 0.1)
  const labelFontSize = Math.max(12, size * 0.04)
  const baseStops = isLightTone
    ? {
        start: "#f7f5ef",
        mid: "#c8c3b8",
        end: "#7f858c",
        inner: "#a8a298",
        label: "#7c8794",
        textStart: "#1d1d1d",
        textMid: "#4b5563",
        textEnd: "#9ca3af",
        shadow: "#6f675d",
        shadowOpacity: "0.18",
      }
    : {
        start: "#ffffff",
        mid: "#d1d5db",
        end: "#6b7280",
        inner: "#6b7280",
        label: "#9ca3af",
        textStart: "#ffffff",
        textMid: "#d1d5db",
        textEnd: "#6b7280",
        shadow: "#000000",
        shadowOpacity: "0.3",
      }

  return (
    <div className={cn("relative", className)} style={{ width: size, height: size * 0.7 }}>
      <svg width={size} height={size * 0.7} viewBox={`0 0 ${size} ${size * 0.7}`} className="overflow-visible">
        <defs>
          {/* Base track gradient - white to silver/gray */}
          <linearGradient id={`baseGradient-${id}`} x1="0%" y1="0%" x2="0%" y2="100%">
            <stop offset="0%" stopColor={baseStops.start} stopOpacity="0.9" />
            <stop offset="50%" stopColor={baseStops.mid} stopOpacity="0.72" />
            <stop offset="100%" stopColor={baseStops.end} stopOpacity="0.62" />
          </linearGradient>

          {/* Progress gradient - orange */}
          <linearGradient id={`progressGradient-${id}`} x1="0%" y1="0%" x2="100%" y2="0%">
            <stop offset="0%" stopColor="#f97316" />
            <stop offset="50%" stopColor="#ea580c" />
            <stop offset="100%" stopColor="#dc2626" />
          </linearGradient>

          {/* Text gradient - white to gray */}
          <linearGradient id={`textGradient-${id}`} x1="0%" y1="0%" x2="100%" y2="0%">
            <stop offset="0%" stopColor={baseStops.textStart} stopOpacity="0.78" />
            <stop offset="50%" stopColor={baseStops.textMid} stopOpacity="0.62" />
            <stop offset="100%" stopColor={baseStops.textEnd} stopOpacity="0.46" />
          </linearGradient>

          {/* Drop shadow filter */}
          <filter id={`dropshadow-${id}`} x="-50%" y="-50%" width="200%" height="200%">
            <feDropShadow dx="0" dy="2" stdDeviation="3" floodColor={baseStops.shadow} floodOpacity={baseStops.shadowOpacity} />
          </filter>
        </defs>

        {/* Inner thin line (1px light gray) */}
        <path
          d={`M ${center - innerLineRadius} ${center} A ${innerLineRadius} ${innerLineRadius} 0 0 1 ${center + innerLineRadius} ${center}`}
          fill="none"
          stroke={baseStops.inner}
          strokeWidth="1"
          strokeLinecap="butt"
          opacity="0.6"
        />

        {/* Base track */}
        <path
          d={`M ${center - radius} ${center} A ${radius} ${radius} 0 0 1 ${center + radius} ${center}`}
          fill="none"
          stroke={`url(#baseGradient-${id})`}
          strokeWidth={strokeWidth}
          strokeLinecap="butt"
          filter={`url(#dropshadow-${id})`}
        />

        {/* Animated Progress track */}
        <motion.path
          d={`M ${center - radius} ${center} A ${radius} ${radius} 0 0 1 ${center + radius} ${center}`}
          fill="none"
          stroke={`url(#progressGradient-${id})`}
          strokeWidth={strokeWidth}
          strokeLinecap="butt"
          strokeDasharray={circumference}
          strokeDashoffset={offset}
          filter={`url(#dropshadow-${id})`}
        />

        {/* Animated extending line */}
        <motion.line
          x1={useTransform(progressAngle, (angle) => center + Math.cos(angle) * innerRadius)}
          y1={useTransform(progressAngle, (angle) => center + Math.sin(angle) * innerRadius)}
          x2={useTransform(progressAngle, (angle) => center + Math.cos(angle) * innerRadius - Math.cos(angle) * 30)}
          y2={useTransform(progressAngle, (angle) => center + Math.sin(angle) * innerRadius - Math.sin(angle) * 30)}
          stroke={`url(#textGradient-${id})`}
          strokeWidth="1"
          strokeLinecap="butt"
        />
      </svg>

      {/* Animated center percentage display with gradient text */}
      <div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center">
        <motion.div
          className="font-bold tracking-tight mt-10"
          style={{ fontSize: `${fontSize}px` }}
          initial={false}
          animate={{ scale: 1 }}
          transition={{ duration: shouldReduceMotion ? 0.15 : 0.5, delay: shouldReduceMotion ? 0 : animationDuration * 0.75 }}
        >
          {isLightTone ? (
            <span className="text-[color:var(--bjork-text-faint)]">
              <span>{displayValue}</span>%
            </span>
          ) : (
            <span
              style={{
                background: "linear-gradient(to right, #ffffff, #9ca3af)",
                WebkitBackgroundClip: "text",
                WebkitTextFillColor: "transparent",
                backgroundClip: "text",
              }}
            >
              <span>{displayValue}</span>%
            </span>
          )}
        </motion.div>
      </div>

      {/* 0% and 100% labels */}
      {showLabels && (
        <>
          <motion.div
            className="absolute font-medium"
            style={{
              fontSize: `${labelFontSize}px`,
              left: center - radius - 5,
              top: center + strokeWidth / 2,
              color: baseStops.label,
            }}
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: shouldReduceMotion ? 0.15 : 0.5, delay: shouldReduceMotion ? 0 : animationDuration * 0.25 }}
          >
            0%
          </motion.div>
          <motion.div
            className="absolute font-medium"
            style={{
              fontSize: `${labelFontSize}px`,
              left: center + radius - 20,
              top: center + strokeWidth / 2,
              color: baseStops.label,
            }}
            initial={{ opacity: 0, y: 10 }}
            animate={{ opacity: 1, y: 0 }}
            transition={{ duration: shouldReduceMotion ? 0.15 : 0.5, delay: shouldReduceMotion ? 0 : animationDuration * 0.25 }}
          >
            100%
          </motion.div>
        </>
      )}
    </div>
  )
}
lib/utils.ts
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"

export function cn(...inputs: ClassValue[]) {
  return twMerge(clsx(inputs))
}

More from bjork-ui

Browse all components in this library →