Steps Chart
Weekly steps and goal card.
Steps
Weekly steps against a goal.
Steps
31,600
total steps29 Jun - 5 Jul29 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.jsonnpx shadcn@latest add https://boardcn.dev/r/steps-card.jsonnpm 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.
"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.
| Prop | Type | Default | Description |
|---|---|---|---|
| getWeekrequired | (weekOffset: number) => StepsWeek | — | Resolve a week relative to the card's starting week (0 = initial). |
| className | string | — | — |