Steps Chart

Weekly steps and goal card.

Steps

Weekly steps against a goal.

Steps

31,600

total steps
29 Jun - 5 Jul

29 Jun - 5 Jul: Monday 5600 steps, Tuesday 2200 steps, Wednesday 1900 steps, Thursday 6300 steps, Friday 7100 steps, Saturday 5300 steps, Sunday 3200 steps.

function StepsChartDemo() {
  return <StepsCard getWeek={getStepsWeek} />;
}
function StepsChartDemo() {
  return <StepsCard getWeek={getStepsWeek} />;
}

Installation

npx shadcn@latest add https://boardcn.dev/r/steps-card.json
npx shadcn@latest add https://boardcn.dev/r/steps-card.json

npm packages

  • recharts

BoardCN dependencies

The CLI installs these for you — you do not need to add them yourself.

Source

The file the CLI copies into your project.

components/charts/steps-card.tsx
"use client";

import { useMemo, useState } from "react";
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

const DAY_NAMES: Record<string, string> = {
  Mon: "Monday",
  Tue: "Tuesday",
  Wed: "Wednesday",
  Thu: "Thursday",
  Fri: "Friday",
  Sat: "Saturday",
  Sun: "Sunday",
};

const BAR_RADIUS: [number, number, number, number] = [10, 10, 10, 10];

export type StepsDatum = {
  label: string;
  value: number;
};

export type StepsWeek = {
  rangeLabel: string;
  data: readonly StepsDatum[];
};

export interface StepsCardProps {
  /** Resolve a week relative to the card's starting week (0 = initial). */
  getWeek: (weekOffset: number) => StepsWeek;
  className?: string;
}

/**
 * BoardCN's medical-dashboard weekly steps card. Hovering a bar promotes its
 * day and value into the headline; the week controls ask the caller for the
 * next week's data and range label.
 */
export function StepsCard({ getWeek, className }: StepsCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [weekOffset, setWeekOffset] = useState(0);

  const week = useMemo(() => getWeek(weekOffset), [getWeek, weekOffset]);
  const data = week.data;
  const total = useMemo(() => data.reduce((sum, point) => sum + point.value, 0), [data]);
  const hasActiveBar =
    activeIndex !== null && activeIndex >= 0 && activeIndex < data.length;
  const headlineValue = hasActiveBar ? data[activeIndex].value : total;
  const activeLabel = hasActiveBar ? data[activeIndex].label : null;
  const headlineLabel = activeLabel
    ? (DAY_NAMES[activeLabel] ?? activeLabel)
    : "Steps";
  const displayedValue = useCountUp(headlineValue);
  const rangeLabel = week.rangeLabel;

  return (
    <section
      aria-label="Weekly steps"
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
    >
      <div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5" aria-live="polite">
          <p className="text-body-medium text-text-secondary">{headlineLabel}</p>
          <div className="flex items-baseline gap-1">
            <p
              key={String(activeIndex)}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
            >
              {displayedValue.toLocaleString()}
            </p>
            <span className="text-caption-1-medium whitespace-nowrap text-text-secondary">
              {hasActiveBar ? "steps" : "total steps"}
            </span>
          </div>
        </div>

        <WeekRangePill
          label={rangeLabel}
          onPrev={() => {
            setActiveIndex(null);
            setWeekOffset((offset) => offset - 1);
          }}
          onNext={() => {
            setActiveIndex(null);
            setWeekOffset((offset) => offset + 1);
          }}
        />
      </div>

      <div className="-mx-[5.5px] min-h-0 flex-1">
        <ResponsiveContainer width="100%" height="100%">
          <BarChart
            data={[...data]}
            margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
            barCategoryGap={4.5}
            onMouseMove={(state) => {
              const nextIndex = Number(state?.activeTooltipIndex);
              setActiveIndex(
                state?.isTooltipActive && Number.isInteger(nextIndex) ? nextIndex : null,
              );
            }}
            onMouseLeave={() => setActiveIndex(null)}
          >
            <Tooltip cursor={false} content={() => null} isAnimationActive={false} />
            <Bar
              dataKey="value"
              fill="var(--color-chart-1)"
              radius={BAR_RADIUS}
              maxBarSize={50}
              background={({ x = 0, y = 0, width = 0, height = 0, index }) => (
                <g>
                  <rect
                    x={x}
                    y={y}
                    width={width}
                    height={height}
                    rx={10}
                    ry={10}
                    fill="var(--color-chart-track)"
                  />
                  <rect
                    className="transition-opacity duration-150 ease-out"
                    x={x - 3}
                    y={y - 3}
                    width={width + 6}
                    height={height + 6}
                    rx={13}
                    ry={13}
                    fill="none"
                    stroke="var(--color-chart-cursor)"
                    strokeWidth={2}
                    opacity={Number(index === activeIndex)}
                  />
                </g>
              )}
              activeBar={false}
              isAnimationActive
              animationDuration={450}
            >
              {data.map((point, index) => (
                <Cell
                  key={point.label}
                  fill={
                    index === activeIndex
                      ? "var(--color-chart-1-active)"
                      : "var(--color-chart-1)"
                  }
                />
              ))}
            </Bar>
            <XAxis
              dataKey="label"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              height={26}
              tick={{ fontSize: 12, fill: "var(--color-text-secondary)" }}
            />
          </BarChart>
        </ResponsiveContainer>
      </div>

      <p className="sr-only">
        {rangeLabel}:{" "}
        {data
          .map(
            (point) =>
              `${DAY_NAMES[point.label] ?? point.label} ${point.value} steps`,
          )
          .join(", ")}
        .
      </p>
    </section>
  );
}
"use client";

import { useMemo, useState } from "react";
import { Bar, BarChart, Cell, ResponsiveContainer, Tooltip, XAxis } from "recharts";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { useCountUp } from "@/hooks/use-count-up";
import { cx } from "@/utils/cx";

const DAY_NAMES: Record<string, string> = {
  Mon: "Monday",
  Tue: "Tuesday",
  Wed: "Wednesday",
  Thu: "Thursday",
  Fri: "Friday",
  Sat: "Saturday",
  Sun: "Sunday",
};

const BAR_RADIUS: [number, number, number, number] = [10, 10, 10, 10];

export type StepsDatum = {
  label: string;
  value: number;
};

export type StepsWeek = {
  rangeLabel: string;
  data: readonly StepsDatum[];
};

export interface StepsCardProps {
  /** Resolve a week relative to the card's starting week (0 = initial). */
  getWeek: (weekOffset: number) => StepsWeek;
  className?: string;
}

/**
 * BoardCN's medical-dashboard weekly steps card. Hovering a bar promotes its
 * day and value into the headline; the week controls ask the caller for the
 * next week's data and range label.
 */
export function StepsCard({ getWeek, className }: StepsCardProps) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  const [weekOffset, setWeekOffset] = useState(0);

  const week = useMemo(() => getWeek(weekOffset), [getWeek, weekOffset]);
  const data = week.data;
  const total = useMemo(() => data.reduce((sum, point) => sum + point.value, 0), [data]);
  const hasActiveBar =
    activeIndex !== null && activeIndex >= 0 && activeIndex < data.length;
  const headlineValue = hasActiveBar ? data[activeIndex].value : total;
  const activeLabel = hasActiveBar ? data[activeIndex].label : null;
  const headlineLabel = activeLabel
    ? (DAY_NAMES[activeLabel] ?? activeLabel)
    : "Steps";
  const displayedValue = useCountUp(headlineValue);
  const rangeLabel = week.rangeLabel;

  return (
    <section
      aria-label="Weekly steps"
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
    >
      <div className="flex w-full items-start justify-between gap-2 px-1.5 pt-1.5">
        <div className="flex min-w-0 flex-1 flex-col gap-0.5" aria-live="polite">
          <p className="text-body-medium text-text-secondary">{headlineLabel}</p>
          <div className="flex items-baseline gap-1">
            <p
              key={String(activeIndex)}
              className="animate-number-fade text-title-1-medium whitespace-nowrap text-text-primary tabular-nums"
            >
              {displayedValue.toLocaleString()}
            </p>
            <span className="text-caption-1-medium whitespace-nowrap text-text-secondary">
              {hasActiveBar ? "steps" : "total steps"}
            </span>
          </div>
        </div>

        <WeekRangePill
          label={rangeLabel}
          onPrev={() => {
            setActiveIndex(null);
            setWeekOffset((offset) => offset - 1);
          }}
          onNext={() => {
            setActiveIndex(null);
            setWeekOffset((offset) => offset + 1);
          }}
        />
      </div>

      <div className="-mx-[5.5px] min-h-0 flex-1">
        <ResponsiveContainer width="100%" height="100%">
          <BarChart
            data={[...data]}
            margin={{ top: 4, right: 0, bottom: 0, left: 0 }}
            barCategoryGap={4.5}
            onMouseMove={(state) => {
              const nextIndex = Number(state?.activeTooltipIndex);
              setActiveIndex(
                state?.isTooltipActive && Number.isInteger(nextIndex) ? nextIndex : null,
              );
            }}
            onMouseLeave={() => setActiveIndex(null)}
          >
            <Tooltip cursor={false} content={() => null} isAnimationActive={false} />
            <Bar
              dataKey="value"
              fill="var(--color-chart-1)"
              radius={BAR_RADIUS}
              maxBarSize={50}
              background={({ x = 0, y = 0, width = 0, height = 0, index }) => (
                <g>
                  <rect
                    x={x}
                    y={y}
                    width={width}
                    height={height}
                    rx={10}
                    ry={10}
                    fill="var(--color-chart-track)"
                  />
                  <rect
                    className="transition-opacity duration-150 ease-out"
                    x={x - 3}
                    y={y - 3}
                    width={width + 6}
                    height={height + 6}
                    rx={13}
                    ry={13}
                    fill="none"
                    stroke="var(--color-chart-cursor)"
                    strokeWidth={2}
                    opacity={Number(index === activeIndex)}
                  />
                </g>
              )}
              activeBar={false}
              isAnimationActive
              animationDuration={450}
            >
              {data.map((point, index) => (
                <Cell
                  key={point.label}
                  fill={
                    index === activeIndex
                      ? "var(--color-chart-1-active)"
                      : "var(--color-chart-1)"
                  }
                />
              ))}
            </Bar>
            <XAxis
              dataKey="label"
              tickLine={false}
              axisLine={false}
              tickMargin={8}
              height={26}
              tick={{ fontSize: 12, fill: "var(--color-text-secondary)" }}
            />
          </BarChart>
        </ResponsiveContainer>
      </div>

      <p className="sr-only">
        {rangeLabel}:{" "}
        {data
          .map(
            (point) =>
              `${DAY_NAMES[point.label] ?? point.label} ${point.value} steps`,
          )
          .join(", ")}
        .
      </p>
    </section>
  );
}

Props

Generated from the component's TypeScript types. Standard DOM and React Aria props are omitted.

StepsCard

BoardCN's medical-dashboard weekly steps card. Hovering a bar promotes its day and value into the headline; the week controls ask the caller for the next week's data and range label.

PropTypeDefaultDescription
getWeekrequired(weekOffset: number) => StepsWeekResolve a week relative to the card's starting week (0 = initial).
classNamestring