Sleep Score

Sleep score and stages card.

Score

Sleep score with stage breakdown.

Sleep score

Excellent

29 Jun - 5 Jul
Duration: 7h 50m
49/50
Bedtime: 20m earlier
29/30
Interruptions: 5m wake up
20/20
function SleepScoreDemo() {
  return (
    <SleepScoreCard metrics={SLEEP_SCORE_METRICS} weekRangeLabel={SLEEP_SCORE_WEEK_RANGE} />
  );
}
function SleepScoreDemo() {
  return (
    <SleepScoreCard metrics={SLEEP_SCORE_METRICS} weekRangeLabel={SLEEP_SCORE_WEEK_RANGE} />
  );
}

Installation

npx shadcn@latest add https://boardcn.dev/r/sleep-score-card.json
npx shadcn@latest add https://boardcn.dev/r/sleep-score-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/sleep-score-card.tsx
"use client";

import { useState, type HTMLAttributes } from "react";
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { cx } from "@/utils/cx";

export type SleepScoreMetric = {
  label: string;
  detail: string;
  score: number;
  max: number;
  color: string;
};

export type SleepScoreCardProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
  metrics: readonly SleepScoreMetric[];
  weekRangeLabel: string;
};

export function SleepScoreCard({
  metrics,
  weekRangeLabel,
  className,
  ...props
}: SleepScoreCardProps) {
  const [activeSegment, setActiveSegment] = useState<number | null>(null);
  const score = metrics.reduce((total, metric) => total + metric.score, 0);
  const maximumScore = metrics.reduce((total, metric) => total + metric.max, 0);
  const segments = [
    ...metrics.map((metric) => ({
      value: metric.score,
      fill: metric.color,
    })),
    {
      value: maximumScore - score,
      fill: "transparent",
    },
  ];
  const hasActiveSegment =
    activeSegment !== null && activeSegment < metrics.length;
  const displayedScore = hasActiveSegment
    ? metrics[activeSegment].score
    : score;

  return (
    <section
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
      {...props}
    >
      <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">
          <p className="text-body-medium text-text-secondary">Sleep score</p>
          <p className="text-title-1-medium whitespace-nowrap text-text-primary">
            {score >= 90
              ? "Excellent"
              : score >= 75
                ? "Good"
                : score >= 50
                  ? "Fair"
                  : "Poor"}
          </p>
        </div>

        <WeekRangePill label={weekRangeLabel} />
      </div>

      <div
        className="relative -mt-2 h-[104px] w-full shrink-0"
        role="img"
        aria-label={`Sleep score ${displayedScore} out of ${maximumScore}`}
      >
        <ResponsiveContainer width="100%" height="100%">
          <PieChart>
            <Pie
              data={[{ value: 1 }]}
              dataKey="value"
              cx="50%"
              cy="50%"
              innerRadius={37}
              outerRadius={52}
              fill="var(--color-chart-track)"
              stroke="none"
              isAnimationActive={false}
            />
            <Pie
              data={segments}
              dataKey="value"
              cx="50%"
              cy="50%"
              innerRadius={37}
              outerRadius={52}
              startAngle={90}
              endAngle={-270}
              cornerRadius={99}
              paddingAngle={4}
              stroke="none"
              onMouseEnter={(_, index) => setActiveSegment(index)}
              onMouseLeave={() => setActiveSegment(null)}
              isAnimationActive
              animationDuration={450}
            >
              {segments.map((segment, index) => (
                <Cell
                  key={`${segment.fill}-${index}`}
                  fill={segment.fill}
                  opacity={
                    hasActiveSegment &&
                    activeSegment !== index &&
                    index < metrics.length
                      ? 0.7
                      : 1
                  }
                  className="transition-opacity duration-200 ease-out motion-reduce:transition-none"
                />
              ))}
            </Pie>
          </PieChart>
        </ResponsiveContainer>

        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <span
            key={String(activeSegment)}
            className="animate-number-fade text-display-4-medium text-text-primary tabular-nums motion-reduce:animate-none"
            aria-live="polite"
          >
            {displayedScore}
          </span>
        </div>
      </div>

      <div className="flex w-full flex-1 flex-col rounded-2lg bg-background-inner-default pl-2.5">
        {metrics.map((metric, index) => (
          <div
            key={metric.label}
            className={cx(
              "flex w-full flex-1 items-center justify-between py-2 pr-2.5",
              index < metrics.length - 1 &&
                "border-b border-separator-border-strong",
            )}
          >
            <div className="flex items-center gap-1.5">
              <span
                className="size-3 shrink-0 rounded-[4px]"
                style={{ backgroundColor: metric.color }}
                aria-hidden="true"
              />
              <span className="text-body-regular whitespace-nowrap text-text-secondary">
                {metric.label}: {metric.detail}
              </span>
            </div>
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {metric.score}/{metric.max}
            </span>
          </div>
        ))}
      </div>
    </section>
  );
}
"use client";

import { useState, type HTMLAttributes } from "react";
import { Cell, Pie, PieChart, ResponsiveContainer } from "recharts";
import { WeekRangePill } from "@/components/blocks/medical/week-range-pill";
import { cx } from "@/utils/cx";

export type SleepScoreMetric = {
  label: string;
  detail: string;
  score: number;
  max: number;
  color: string;
};

export type SleepScoreCardProps = Omit<HTMLAttributes<HTMLElement>, "children"> & {
  metrics: readonly SleepScoreMetric[];
  weekRangeLabel: string;
};

export function SleepScoreCard({
  metrics,
  weekRangeLabel,
  className,
  ...props
}: SleepScoreCardProps) {
  const [activeSegment, setActiveSegment] = useState<number | null>(null);
  const score = metrics.reduce((total, metric) => total + metric.score, 0);
  const maximumScore = metrics.reduce((total, metric) => total + metric.max, 0);
  const segments = [
    ...metrics.map((metric) => ({
      value: metric.score,
      fill: metric.color,
    })),
    {
      value: maximumScore - score,
      fill: "transparent",
    },
  ];
  const hasActiveSegment =
    activeSegment !== null && activeSegment < metrics.length;
  const displayedScore = hasActiveSegment
    ? metrics[activeSegment].score
    : score;

  return (
    <section
      className={cx(
        "flex h-[330px] w-full min-w-0 flex-col gap-4 rounded-[20px] bg-background-secondary-default p-2.5",
        className,
      )}
      {...props}
    >
      <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">
          <p className="text-body-medium text-text-secondary">Sleep score</p>
          <p className="text-title-1-medium whitespace-nowrap text-text-primary">
            {score >= 90
              ? "Excellent"
              : score >= 75
                ? "Good"
                : score >= 50
                  ? "Fair"
                  : "Poor"}
          </p>
        </div>

        <WeekRangePill label={weekRangeLabel} />
      </div>

      <div
        className="relative -mt-2 h-[104px] w-full shrink-0"
        role="img"
        aria-label={`Sleep score ${displayedScore} out of ${maximumScore}`}
      >
        <ResponsiveContainer width="100%" height="100%">
          <PieChart>
            <Pie
              data={[{ value: 1 }]}
              dataKey="value"
              cx="50%"
              cy="50%"
              innerRadius={37}
              outerRadius={52}
              fill="var(--color-chart-track)"
              stroke="none"
              isAnimationActive={false}
            />
            <Pie
              data={segments}
              dataKey="value"
              cx="50%"
              cy="50%"
              innerRadius={37}
              outerRadius={52}
              startAngle={90}
              endAngle={-270}
              cornerRadius={99}
              paddingAngle={4}
              stroke="none"
              onMouseEnter={(_, index) => setActiveSegment(index)}
              onMouseLeave={() => setActiveSegment(null)}
              isAnimationActive
              animationDuration={450}
            >
              {segments.map((segment, index) => (
                <Cell
                  key={`${segment.fill}-${index}`}
                  fill={segment.fill}
                  opacity={
                    hasActiveSegment &&
                    activeSegment !== index &&
                    index < metrics.length
                      ? 0.7
                      : 1
                  }
                  className="transition-opacity duration-200 ease-out motion-reduce:transition-none"
                />
              ))}
            </Pie>
          </PieChart>
        </ResponsiveContainer>

        <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
          <span
            key={String(activeSegment)}
            className="animate-number-fade text-display-4-medium text-text-primary tabular-nums motion-reduce:animate-none"
            aria-live="polite"
          >
            {displayedScore}
          </span>
        </div>
      </div>

      <div className="flex w-full flex-1 flex-col rounded-2lg bg-background-inner-default pl-2.5">
        {metrics.map((metric, index) => (
          <div
            key={metric.label}
            className={cx(
              "flex w-full flex-1 items-center justify-between py-2 pr-2.5",
              index < metrics.length - 1 &&
                "border-b border-separator-border-strong",
            )}
          >
            <div className="flex items-center gap-1.5">
              <span
                className="size-3 shrink-0 rounded-[4px]"
                style={{ backgroundColor: metric.color }}
                aria-hidden="true"
              />
              <span className="text-body-regular whitespace-nowrap text-text-secondary">
                {metric.label}: {metric.detail}
              </span>
            </div>
            <span className="text-body-medium whitespace-nowrap text-text-primary tabular-nums">
              {metric.score}/{metric.max}
            </span>
          </div>
        ))}
      </div>
    </section>
  );
}

Props

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

SleepScoreCard

PropTypeDefaultDescription
metricsrequiredreadonly SleepScoreMetric[]
weekRangeLabelrequiredstring