For the complete documentation index, see llms.txt. Markdown variants are available by appending .md to documentation URLs. Homepage markdown is available at /llms.md/content.md. An agent skill is available at /.well-known/agent-skills/site-skill.md.

Steps Card 01

A weekly activity chart with a week switcher.

Installation

Set up the @cobalt registry first, as described in Installation, then add the block:

$ pnpm dlx shadcn@latest add @cobalt/steps-card-01

Pass an array of weeks, each with a display label and up to seven daily values. The title and unit can be customized.

Source

"use client";

import { cn } from "cn";
import * as React from "react";

import { useCountUp } from "@/components/hooks/use-count-up";
import { Button } from "@/components/ui/button";
import {
  Card,
  CardAction,
  CardContent,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  PeriodSwitcher,
  PeriodSwitcherLabel,
  PeriodSwitcherNext,
  PeriodSwitcherPrevious,
} from "@/components/ui/period-switcher";
import { Reveal } from "@/components/ui/reveal";

export interface StepsDay {
  label: string;
  value: number;
}

export interface StepsWeek {
  label: string;
  days: StepsDay[];
}

export function StepsCard({
  weeks,
  title = "Activity",
  unit = "steps recorded",
  className,
}: {
  weeks: StepsWeek[];
  title?: string;
  unit?: string;
  className?: string;
}) {
  const [index, setIndex] = React.useState(0);
  const [hoveredDay, setHoveredDay] = React.useState<number | null>(null);
  const [focusedDay, setFocusedDay] = React.useState<number | null>(null);
  const week = weeks[Math.min(index, Math.max(weeks.length - 1, 0))];
  const total =
    week?.days.reduce((sum, day) => sum + Math.max(0, day.value), 0) ?? 0;

  const max = Math.max(1, ...(week?.days.map((day) => day.value) ?? [])) * 1.12;
  const activeDay = week?.days[hoveredDay ?? focusedDay ?? -1];
  const activeValue = activeDay ? Math.max(0, activeDay.value) : total;
  const valueRef = useCountUp<HTMLSpanElement>(activeValue);

  return (
    <Reveal
      variant="card"
      render={
        <Card
          radius={20}
          variant="muted"
          className={cn("w-full max-w-[576px]", className)}
        />
      }
    >
      <CardHeader className="gap-0">
        <CardTitle variant="secondary">{title}</CardTitle>
        <div className="flex items-baseline gap-1">
          <span
            ref={valueRef}
            className="text-2xl leading-8.5 font-medium tabular-nums"
            aria-label={`${activeDay ? `${activeDay.label}: ` : ""}${activeValue.toLocaleString()} ${unit}`}
          >
            {activeValue.toLocaleString("en-US")}
          </span>
          <span className="text-xs text-muted-foreground" aria-hidden="true">
            {unit}
            {activeDay ? ` · ${activeDay.label}` : ""}
          </span>
        </div>
        <CardAction>
          <PeriodSwitcher>
            <PeriodSwitcherPrevious
              tooltip="Previous week"
              disabled={index <= 0}
              onClick={() => setIndex((current) => Math.max(0, current - 1))}
            />
            <PeriodSwitcherLabel className="min-w-[110px]">
              {week?.label ?? "No weeks"}
            </PeriodSwitcherLabel>
            <PeriodSwitcherNext
              tooltip="Next week"
              disabled={index >= weeks.length - 1}
              onClick={() =>
                setIndex((current) => Math.min(weeks.length - 1, current + 1))
              }
            />
          </PeriodSwitcher>
        </CardAction>
      </CardHeader>
      <CardContent>
        <div
          className="grid grid-cols-7 gap-2"
          aria-label={`${week?.label ?? "Week"} activity`}
        >
          {week?.days.slice(0, 7).map((day, i) => (
            <div
              key={`${i}-${day.label}`}
              className="flex min-w-0 flex-col items-center gap-2.5"
            >
              <div className="h-50.5 w-full max-w-12.5">
                <Button
                  variant="unstyled"
                  radius={10}
                  aria-label={`${day.label}: ${Math.max(0, day.value).toLocaleString()} ${unit}`}
                  onMouseEnter={() => setHoveredDay(i)}
                  onMouseLeave={() => setHoveredDay(null)}
                  onFocus={() => setFocusedDay(i)}
                  onBlur={() => setFocusedDay(null)}
                  className="group relative h-full w-full"
                >
                  <span
                    aria-hidden="true"
                    className="absolute inset-0 rounded-[10px] bg-(--border-subtle,var(--input))"
                  />
                  <div
                    className="absolute inset-x-0 bottom-0 h-(--bar-height) rounded-[10px] bg-(--steps-card-fill,var(--chart-1)) transition-all duration-450 ease-out starting:h-0 group-hover:ring-2 group-hover:ring-(--steps-card-fill,var(--chart-1)) group-hover:ring-offset-2 group-hover:ring-offset-(--border-subtle,var(--input)) motion-reduce:transition-none"
                    style={
                      {
                        "--bar-height": `${(Math.max(0, day.value) / max) * 100}%`,
                      } as React.CSSProperties
                    }
                  />
                </Button>
              </div>
              <span className="text-xs leading-4 text-muted-foreground">
                {day.label}
              </span>
            </div>
          ))}
        </div>
      </CardContent>
    </Reveal>
  );
}