New: Activity Terrain +12 more

Apple Activity Card

Activity rings that draw with a count-up, lap past 100% with a shadowed end cap and dim to focus, built with React and Motion.

1 exampleDepends onmotionFrom KokonutUI (MIT)

Installation

Run this in your project. It adds the files, installs the npm packages and anything this builds on, and sets up the theme:

npx shadcn@latest add https://kryptonite-ui.vercel.app/r/apple-activity-card.json
Install manually
  1. Install the dependencies:

    npm install motion
  2. Copy this into components/kokonut/apple-activity-card.tsx:

    apple-activity-card.tsx
    "use client";
    
    /**
     * @author: @kokonutui
     * @description: Apple Activity Card
     * @version: 1.1.0
     * @date: 2026-10-14
     * @license: MIT
     * @website: https://kokonutui.com
     * @github: https://github.com/kokonut-labs/kokonutui
     */
    
    import {
      animate,
      MotionConfig,
      motion,
      useInView,
      useReducedMotion,
    } from "motion/react";
    import { type CSSProperties, useEffect, useId, useRef, useState } from "react";
    import { cn } from "@/lib/utils";
    
    export interface ActivityData {
      label: string;
      color: string;
      colorEnd: string;
      size: number;
      /** Can go past `target`: the ring keeps going for another lap. */
      current: number;
      target: number;
      unit: string;
    }
    
    const EASE_OUT = [0.23, 1, 0.32, 1] as const;
    const STROKE = 16;
    // Rings draw one after another, this far apart (s).
    const STAGGER = 0.18;
    // Time for the first lap; anything past the goal adds up to OVER_S more.
    const DRAW_S = 1.2;
    const OVER_S = 0.6;
    // More than three laps would just be a solid ring.
    const MAX_LAPS = 3;
    
    const SAMPLE_ACTIVITIES: ActivityData[] = [
      {
        label: "MOVE",
        color: "#FF2D55",
        colorEnd: "#FF6B8B",
        size: 200,
        current: 479,
        target: 800,
        unit: "CAL",
      },
      {
        label: "EXERCISE",
        color: "#A3F900",
        colorEnd: "#C5FF4D",
        size: 160,
        current: 42,
        target: 30,
        unit: "MIN",
      },
      {
        label: "STAND",
        color: "#04C7DD",
        colorEnd: "#4DDFED",
        size: 120,
        current: 6,
        target: 12,
        unit: "HR",
      },
    ];
    
    /** Progress in laps (1 = goal), capped so a huge value stays legible. */
    const ratioOf = (data: ActivityData) =>
      data.target > 0
        ? Math.min(Math.max(data.current / data.target, 0), MAX_LAPS)
        : 0;
    
    /** Duration of one ring's draw; its count-up runs on the same clock. */
    const drawSeconds = (ratio: number) =>
      DRAW_S + OVER_S * Math.min(Math.max(ratio - 1, 0), 1);
    
    interface RingNodes {
      base: SVGCircleElement | null;
      lap: SVGCircleElement | null;
      cap: SVGGElement | null;
      pulse: SVGCircleElement | null;
      count: HTMLSpanElement | null;
    }
    
    /**
     * Write one frame of a ring's draw straight to the DOM: `p` is progress in
     * laps. Past the goal the first lap stays full and a second circle draws
     * the overflow on top of it, led by a shadowed end cap, as on the Watch.
     */
    function paintRing(
      nodes: RingNodes,
      data: Pick<ActivityData, "size" | "current">,
      p: number,
      ratio: number
    ) {
      const radius = (data.size - STROKE) / 2;
      const circumference = radius * 2 * Math.PI;
      nodes.base?.setAttribute(
        "stroke-dashoffset",
        `${(1 - Math.min(p, 1)) * circumference}`
      );
      const over = p > 1;
      // How far into the latest lap; a whole number of laps is a full lap.
      const rest = over ? p - Math.floor(p) || 1 : 0;
      if (nodes.lap) {
        nodes.lap.setAttribute(
          "stroke-dashoffset",
          `${(1 - rest) * circumference}`
        );
        nodes.lap.style.opacity = over ? "1" : "0";
      }
      if (nodes.cap) {
        nodes.cap.style.transform = `rotate(${rest * 360}deg)`;
        nodes.cap.style.opacity = over ? "1" : "0";
      }
      if (nodes.count) {
        // Count in step with the ring, landing exactly on the real value.
        nodes.count.textContent =
          p >= ratio
            ? `${data.current}`
            : `${Math.round((p / ratio) * data.current)}`;
      }
    }
    
    function CircleProgress({
      data,
      index,
      inView,
      reduceMotion,
      dimmed,
      onActive,
      countRef,
    }: {
      data: ActivityData;
      index: number;
      inView: boolean;
      reduceMotion: boolean;
      dimmed: boolean;
      onActive: (active: boolean) => void;
      countRef: { current: HTMLSpanElement | null };
    }) {
      const radius = (data.size - STROKE) / 2;
      const circumference = radius * 2 * Math.PI;
      const center = data.size / 2;
      const ratio = ratioOf(data);
      const progressLabel = `${data.label}: ${data.current} of ${data.target} ${data.unit}, ${Math.round(ratio * 100)}%`;
    
      const gradientId = useId();
      const shadowId = useId();
      const nodes = useRef<RingNodes>({
        base: null,
        lap: null,
        cap: null,
        pulse: null,
        count: null,
      });
    
      // Where the ring last stood, so new values carry on from there instead of
      // redrawing from empty. Null until the first draw.
      const shown = useRef<number | null>(null);
      // Keyed on the values, not the object: a parent re-rendering with the same
      // numbers in a fresh array must not restart the draw.
      const { size, current } = data;
    
      useEffect(() => {
        if (!inView) {
          return;
        }
        const ringNodes = { ...nodes.current, count: countRef.current };
        const ring = { size, current };
        if (reduceMotion || ratio === 0) {
          paintRing(ringNodes, ring, ratio, ratio);
          shown.current = ratio;
          return;
        }
        const from = shown.current;
        // First draw: start from zero right away, so the number doesn't show its
        // final value while the ring waits for its turn.
        if (from === null) {
          paintRing(ringNodes, ring, 0, ratio);
        }
        let pulsed = (from ?? 0) >= 1;
        const controls = animate(from ?? 0, ratio, {
          duration: drawSeconds(ratio),
          delay: from === null ? index * STAGGER : 0,
          ease: EASE_OUT,
          onUpdate: (p) => {
            shown.current = p;
            paintRing(ringNodes, ring, p, ratio);
            // The goal is hit: the cap pulses once at the top of the ring.
            if (!pulsed && p >= 1) {
              pulsed = true;
              ringNodes.pulse?.animate(
                [
                  { transform: "scale(1)", opacity: 0.6 },
                  { transform: "scale(2.4)", opacity: 0 },
                ],
                { duration: 560, easing: "cubic-bezier(0.23, 1, 0.32, 1)" }
              );
            }
          },
        });
        return () => controls.stop();
      }, [inView, reduceMotion, size, current, ratio, index, countRef]);
    
      return (
        <motion.div
          animate={inView ? { opacity: 1, transform: "scale(1)" } : undefined}
          className="pointer-events-none absolute inset-0 flex items-center justify-center"
          initial={{ opacity: 0, transform: "scale(0.96)" }}
          transition={{ duration: 0.3, delay: index * STAGGER, ease: EASE_OUT }}
        >
          {/* biome-ignore lint/a11y: the ring is focusable so keyboard users get the same focus dimming as hover; it has no action of its own. */}
          <svg
            aria-label={progressLabel}
            className={cn(
              "-rotate-90 overflow-visible rounded-full outline-none transition-opacity duration-200 ease-out focus-visible:ring-2 focus-visible:ring-zinc-400",
              // Neon hues wash out on white, so light mode caps their lightness
              // (hue and chroma kept) and the gradient deepens instead of fading.
              "[--ring-from:oklch(from_var(--ring)_min(l,0.8)_c_h)] [--ring-to:oklch(from_var(--ring)_min(l,0.7)_c_h)]",
              "dark:[--ring-from:var(--ring)] dark:[--ring-to:var(--ring-end)]",
              dimmed && "opacity-25"
            )}
            height={data.size}
            onBlur={() => onActive(false)}
            onFocus={() => onActive(true)}
            role="img"
            style={
              { "--ring": data.color, "--ring-end": data.colorEnd } as CSSProperties
            }
            // biome-ignore lint/a11y/noNoninteractiveTabindex: see above; focus only dims the other rings.
            tabIndex={0}
            viewBox={`0 0 ${data.size} ${data.size}`}
            width={data.size}
          >
            <title>{progressLabel}</title>
            <defs>
              <linearGradient id={gradientId} x1="0%" x2="100%" y1="0%" y2="100%">
                <stop offset="0%" style={{ stopColor: "var(--ring-from)" }} />
                <stop offset="100%" style={{ stopColor: "var(--ring-to)" }} />
              </linearGradient>
              {/* Lets the second lap read as lying on top of the first. */}
              <filter height="300%" id={shadowId} width="300%" x="-100%" y="-100%">
                <feDropShadow
                  dx="0"
                  dy="-2.5"
                  floodColor="#000"
                  floodOpacity="0.5"
                  stdDeviation="2"
                />
              </filter>
            </defs>
    
            {/* The track is the ring's hover target, so each ring only reacts
                over its own band. */}
            <circle
              className="text-zinc-200/60 dark:text-zinc-800/60"
              cx={center}
              cy={center}
              fill="none"
              onPointerEnter={(event) => {
                if (event.pointerType === "mouse") {
                  onActive(true);
                }
              }}
              onPointerLeave={() => onActive(false)}
              pointerEvents="stroke"
              r={radius}
              stroke="currentColor"
              strokeWidth={STROKE}
            />
            <circle
              cx={center}
              cy={center}
              fill="none"
              pointerEvents="none"
              r={radius}
              ref={(node) => {
                nodes.current.base = node;
              }}
              stroke={`url(#${gradientId})`}
              strokeDasharray={circumference}
              strokeDashoffset={circumference}
              strokeLinecap="round"
              strokeWidth={STROKE}
            />
            <circle
              cx={center}
              cy={center}
              fill="none"
              pointerEvents="none"
              r={radius}
              ref={(node) => {
                nodes.current.lap = node;
              }}
              strokeDasharray={circumference}
              strokeDashoffset={circumference}
              strokeLinecap="round"
              strokeWidth={STROKE}
              style={{ opacity: 0, stroke: "var(--ring-to)" }}
            />
            {/* End cap of the overflow lap; its shadow shows it passing over
                the start of the ring. */}
            <g
              pointerEvents="none"
              ref={(node) => {
                nodes.current.cap = node;
              }}
              style={{
                opacity: 0,
                transformBox: "view-box",
                transformOrigin: "center",
              }}
            >
              <circle
                cx={center + radius}
                cy={center}
                filter={`url(#${shadowId})`}
                r={STROKE / 2}
                style={{ fill: "var(--ring-to)" }}
              />
            </g>
            {/* Goal pulse, at the top of the ring where the first lap closes. */}
            <circle
              cx={center + radius}
              cy={center}
              fill="none"
              pointerEvents="none"
              r={STROKE / 2}
              ref={(node) => {
                nodes.current.pulse = node;
              }}
              strokeWidth={2}
              style={{
                opacity: 0,
                stroke: "var(--ring-to)",
                transformBox: "fill-box",
                transformOrigin: "center",
              }}
            />
          </svg>
        </motion.div>
      );
    }
    
    function DetailedActivityInfo({
      activities,
      inView,
      active,
      onActive,
      countRefs,
    }: {
      activities: ActivityData[];
      inView: boolean;
      active: number | null;
      onActive: (index: number | null) => void;
      countRefs: { current: HTMLSpanElement | null }[];
    }) {
      return (
        <motion.ul
          animate={
            inView ? { opacity: 1, transform: "translateX(0px)" } : undefined
          }
          className="flex flex-col gap-6"
          initial={{ opacity: 0, transform: "translateX(16px)" }}
          transition={{ duration: 0.5, delay: 0.3, ease: EASE_OUT }}
        >
          {activities.map((activity, index) => (
            <li
              className={cn(
                "flex flex-col transition-opacity duration-200 ease-out",
                active !== null && active !== index && "opacity-40"
              )}
              key={activity.label}
              onPointerEnter={(event) => {
                if (event.pointerType === "mouse") {
                  onActive(index);
                }
              }}
              onPointerLeave={() => onActive(null)}
            >
              <span className="font-medium text-sm text-zinc-600 dark:text-zinc-400">
                {activity.label}
              </span>
              {/* The ring hues are too light for text on white, so light mode
                  sets them to a fixed, deeper lightness with the same hue and
                  chroma (3:1 or better for this large text). */}
              <span
                className="font-semibold text-2xl text-[oklch(from_var(--activity-color)_0.6_c_h)] tabular-nums dark:text-(--activity-color)"
                style={{ "--activity-color": activity.color } as CSSProperties}
              >
                <span className="sr-only">
                  {activity.current} of {activity.target} {activity.unit}
                </span>
                <span aria-hidden="true">
                  <span
                    ref={(node) => {
                      const slot = countRefs[index];
                      if (slot) {
                        slot.current = node;
                      }
                    }}
                  >
                    {activity.current}
                  </span>
                  /{activity.target}
                  <span className="ml-1 text-base text-zinc-600 dark:text-zinc-400">
                    {activity.unit}
                  </span>
                </span>
              </span>
            </li>
          ))}
        </motion.ul>
      );
    }
    
    export default function AppleActivityCard({
      title = "Activity Rings",
      titleAs = "h2",
      activities = SAMPLE_ACTIVITIES,
      className,
    }: {
      title?: string;
      /** Heading level for the title, to fit the page outline. */
      titleAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
      /** Rings, outermost first. Defaults to sample data. */
      activities?: ActivityData[];
      className?: string;
    }) {
      const ref = useRef<HTMLDivElement>(null);
      const Heading = motion[titleAs];
      const boxSize = activities.length
        ? Math.max(...activities.map((activity) => activity.size))
        : 0;
      const inView = useInView(ref, { once: true, margin: "-40px" });
      const reduceMotion = useReducedMotion() ?? false;
      const [active, setActive] = useState<number | null>(null);
      // The legend's numbers, counted up by the ring that owns them.
      const countRefs = useRef<{ current: HTMLSpanElement | null }[]>([]);
      countRefs.current = activities.map(
        (_, index) => countRefs.current[index] ?? { current: null }
      );
    
      return (
        <div
          className={cn(
            "@container relative mx-auto w-full max-w-3xl rounded-3xl p-8",
            "text-zinc-900 dark:text-white",
            className
          )}
          ref={ref}
        >
          {/* Reduced motion keeps the fades and drops the scale/slide. */}
          <MotionConfig reducedMotion="user">
            <div className="flex flex-col items-center gap-8">
              <Heading
                animate={
                  inView ? { opacity: 1, transform: "translateY(0px)" } : undefined
                }
                className="font-medium text-2xl text-zinc-900 dark:text-white"
                initial={{ opacity: 0, transform: "translateY(-12px)" }}
                transition={{ duration: 0.5, ease: EASE_OUT }}
              >
                {title}
              </Heading>
    
              <div className="flex @md:flex-row flex-col items-center gap-8">
                <div
                  className="relative shrink-0"
                  style={{ width: boxSize, height: boxSize }}
                >
                  {activities.map((activity, index) => (
                    <CircleProgress
                      countRef={countRefs.current[index]}
                      data={activity}
                      dimmed={active !== null && active !== index}
                      index={index}
                      inView={inView}
                      key={activity.label}
                      onActive={(on) =>
                        setActive((prev) => {
                          if (on) {
                            return index;
                          }
                          return prev === index ? null : prev;
                        })
                      }
                      reduceMotion={reduceMotion}
                    />
                  ))}
                </div>
                <DetailedActivityInfo
                  active={active}
                  activities={activities}
                  countRefs={countRefs.current}
                  inView={inView}
                  onActive={setActive}
                />
              </div>
            </div>
          </MotionConfig>
        </div>
      );
    }

Feedback

Questions, bugs or ideas for Apple Activity Card? Post them here, or reply to someone else. Sign in with GitHub to comment.