Sleep Score
Sleep score and stages card.
Score
Sleep score with stage breakdown.
Sleep score
Excellent
29 Jun - 5 Jul
98
Duration: 7h 50m
49/50Bedtime: 20m earlier
29/30Interruptions: 5m wake up
20/20function 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.jsonnpx shadcn@latest add https://boardcn.dev/r/sleep-score-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 { 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
| Prop | Type | Default | Description |
|---|---|---|---|
| metricsrequired | readonly SleepScoreMetric[] | — | — |
| weekRangeLabelrequired | string | — | — |