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.

Sleep Score Card 01

A weekly sleep summary with an interactive score ring.

Installation

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

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

Pass one or more periods, each with a label, summary, and three metrics. Hover or focus a metric to highlight its segment; use the arrows to move between periods.

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,
  CardDescription,
  CardHeader,
  CardTitle,
} from "@/components/ui/card";
import {
  PeriodSwitcher,
  PeriodSwitcherLabel,
  PeriodSwitcherNext,
  PeriodSwitcherPrevious,
} from "@/components/ui/period-switcher";
import { Reveal } from "@/components/ui/reveal";
import { Separator } from "@/components/ui/separator";

export interface SleepScoreMetric {
  label: string;
  detail: string;
  value: number;
  max: number;
}

export interface SleepScorePeriod {
  label: string;
  summary: string;
  metrics: [SleepScoreMetric, SleepScoreMetric, SleepScoreMetric];
}

const colors = [
  "var(--sleep-score-duration, var(--chart-1))",
  "var(--sleep-score-routine, var(--chart-2))",
  "var(--sleep-score-rest, var(--chart-3))",
];

export function SleepScoreCard({
  periods,
  className,
}: {
  periods: [SleepScorePeriod, ...SleepScorePeriod[]];
  className?: string;
}) {
  const [periodIndex, setPeriodIndex] = React.useState(0);
  const [active, setActive] = React.useState<number | null>(null);
  const period = periods[Math.min(periodIndex, periods.length - 1)];
  const score = period.metrics.reduce(
    (total, metric) => total + metric.value,
    0
  );
  const scoreRef = useCountUp<HTMLSpanElement>(score);
  const maximum = period.metrics.reduce(
    (total, metric) => total + Math.max(0, metric.max),
    0
  );
  let offset = 0;

  return (
    <Reveal
      variant="card"
      render={
        <Card
          radius={20}
          variant="muted"
          size="xs"
          className={cn("@container w-full max-w-md", className)}
          aria-label="Sleep score"
        />
      }
    >
      <CardHeader>
        <div>
          <CardDescription>Sleep score</CardDescription>
          <CardTitle size="lg">{period.summary}</CardTitle>
        </div>
        <CardAction>
          <PeriodSwitcher>
            <PeriodSwitcherPrevious
              disabled={periodIndex === 0}
              onClick={() => {
                setPeriodIndex((index) => index - 1);
                setActive(null);
              }}
            />
            <PeriodSwitcherLabel className="min-w-[110px]">
              {period.label}
            </PeriodSwitcherLabel>
            <PeriodSwitcherNext
              disabled={periodIndex >= periods.length - 1}
              onClick={() => {
                setPeriodIndex((index) => index + 1);
                setActive(null);
              }}
            />
          </PeriodSwitcher>
        </CardAction>
      </CardHeader>
      <div
        className="relative mx-auto mb-1.5 flex size-[104px] items-center justify-center"
        role="img"
        aria-label={`Sleep score ${score} out of ${maximum}`}
      >
        <svg
          viewBox="0 0 104 104"
          className="absolute inset-0 size-full -rotate-90"
          aria-hidden="true"
        >
          <circle
            cx="52"
            cy="52"
            r="45"
            fill="none"
            stroke="var(--command-border, var(--border))"
            strokeWidth="14"
          />
          {period.metrics.map((metric, index) => {
            const portion =
              maximum > 0 ? (Math.max(0, metric.max) / maximum) * 100 : 0;
            const length = Math.max(0, portion - 4);
            const start = offset + 2;
            offset += portion;
            return (
              <circle
                key={`${periodIndex}-${metric.label}`}
                cx="52"
                cy="52"
                r="45"
                fill="none"
                stroke={colors[index]}
                strokeWidth="14"
                strokeLinecap="round"
                pathLength="100"
                strokeDasharray={`${length} ${100 - length}`}
                strokeDashoffset={-start}
                opacity={active === null || active === index ? 1 : 0.25}
                className="sleep-score-card-01-draw transition-opacity duration-150 motion-reduce:transition-none"
              />
            );
          })}
        </svg>
        <span
          ref={scoreRef}
          className="text-3xl leading-none font-medium tabular-nums"
        >
          {score.toLocaleString("en-US")}
        </span>
      </div>
      <CardContent>
        <Card variant="raised" size="xs" radius={10}>
          <CardContent className="gap-0">
            {period.metrics.map((metric, index) => (
              <React.Fragment key={metric.label}>
                {index > 0 && <Separator />}
                <div className="flex min-h-8">
                  <Button
                    variant="unstyled"
                    type="button"
                    onMouseEnter={() => setActive(index)}
                    onMouseLeave={() => setActive(null)}
                    onFocus={() => setActive(index)}
                    onBlur={() => setActive(null)}
                    className="w-full justify-start gap-2 text-left whitespace-normal"
                  >
                    <svg
                      viewBox="0 0 12 12"
                      className="size-3 shrink-0"
                      aria-hidden="true"
                    >
                      <rect
                        width="12"
                        height="12"
                        rx="4"
                        fill={colors[index]}
                      />
                    </svg>
                    <span className="min-w-0 flex-1 font-normal text-muted-foreground">
                      {metric.label}
                      <span className="hidden @[280px]:inline">
                        : {metric.detail}
                      </span>
                    </span>
                    <span className="shrink-0 font-medium tabular-nums">
                      {metric.value}/{metric.max}
                    </span>
                  </Button>
                </div>
              </React.Fragment>
            ))}
          </CardContent>
        </Card>
      </CardContent>
    </Reveal>
  );
}