Cards
Activity Card
A compact analytics card with an interactive daily chart, computed totals, and a quiet sage palette.
#chart#analytics#bento#dashboard
Original by Plinth · MIT
Report a problem or suggest an improvementInstall
npx shadcn@latest add https://plinthui.com/r/activity-card.json"use client";
import { useState } from "react";
const css = String.raw`
.ac-card {
--ac-ink: #26382f;
--ac-muted: #6c7c6f;
--ac-surface: #f3f6ee;
--ac-line: #dae3d4;
--ac-bar: #799665;
--ac-hot: #c5e39a;
box-sizing: border-box;
width: min(340px, 100%);
padding: 22px 24px 18px;
border: 1px solid var(--ac-line);
border-radius: 24px;
color: var(--ac-ink);
background:
radial-gradient(ellipse at 90% 0, #c7e4a526, transparent 70%),
var(--ac-surface);
box-shadow:
inset 0 1px 0 #ffffff42,
0 14px 35px #1b291812;
font:
13px/1.45 system-ui,
sans-serif;
}
.ac-card * {
box-sizing: border-box;
}
.ac-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.ac-label {
display: flex;
align-items: center;
gap: 7px;
font-size: 12px;
font-weight: 500;
}
.ac-label svg {
width: 15px;
height: 15px;
color: var(--ac-muted);
}
.ac-period {
display: flex;
gap: 2px;
padding: 3px;
border: 1px solid var(--ac-line);
border-radius: 9px;
}
.ac-period button {
font:
10px/1.4 ui-monospace,
monospace;
min-height: 25px;
padding: 4px 7px;
border: 0;
border-radius: 5px;
background: none;
color: var(--ac-muted);
cursor: pointer;
}
.ac-period button[aria-pressed="true"] {
color: var(--ac-ink);
background: #78996123;
box-shadow: 0 1px 2px #2431190d;
}
.ac-period button:focus-visible {
outline: 2px solid var(--ac-bar);
outline-offset: 3px;
}
.ac-number {
margin: 17px 0 0;
font-size: 43px;
font-weight: 500;
line-height: 1.05;
letter-spacing: -0.07em;
font-variant-numeric: tabular-nums;
}
.ac-summary {
margin: 7px 0 0;
font-size: 10px;
color: var(--ac-muted);
}
.ac-chart {
position: relative;
display: flex;
align-items: flex-end;
gap: 7px;
height: 105px;
list-style: none;
padding: 0;
margin: 23px 0 0;
border-bottom: 1px solid var(--ac-line);
background: repeating-linear-gradient(
to top,
transparent 0,
transparent 34px,
var(--ac-line) 34px,
var(--ac-line) 35px
);
}
.ac-chart li {
display: flex;
align-items: flex-end;
flex: 1;
min-width: 0;
height: 100%;
position: relative;
}
.ac-bar {
display: block;
width: 100%;
min-height: 2px;
border-radius: 5px 5px 0 0;
background: linear-gradient(
to bottom,
var(--ac-bar),
color-mix(in srgb, var(--ac-bar) 64%, var(--ac-surface))
);
transform-origin: bottom;
animation: ac-grow 420ms cubic-bezier(0.2, 0.8, 0.2, 1) both;
}
.ac-chart li:last-child .ac-bar {
background: linear-gradient(to bottom, var(--ac-hot), var(--ac-bar));
box-shadow: inset 0 1px 0 #fff5;
}
.ac-chart li:hover .ac-bar {
filter: brightness(1.15);
}
.ac-tip {
position: absolute;
pointer-events: none;
bottom: calc(100% + 5px);
left: 50%;
transform: translateX(-50%);
z-index: 1;
padding: 5px 7px;
white-space: nowrap;
background: var(--ac-ink);
color: var(--ac-surface);
border-radius: 6px;
font-size: 10px;
opacity: 0;
}
.ac-chart li:hover .ac-tip {
opacity: 1;
}
.ac-axis {
display: flex;
justify-content: space-between;
margin: 8px 0 0;
color: var(--ac-muted);
font:
9px/1.4 ui-monospace,
monospace;
text-transform: uppercase;
letter-spacing: 0.06em;
}
.ac-sr {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
white-space: nowrap;
}
.ac-empty {
margin: 25px 0;
color: var(--ac-muted);
}
:root[data-theme="dark"] .ac-card {
--ac-ink: #e8efdf;
--ac-muted: #a0b093;
--ac-surface: #222b23;
--ac-line: #3a4837;
--ac-bar: #76975c;
--ac-hot: #d5f4a6;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .ac-card {
--ac-ink: #e8efdf;
--ac-muted: #a0b093;
--ac-surface: #222b23;
--ac-line: #3a4837;
--ac-bar: #76975c;
--ac-hot: #d5f4a6;
}
}
@keyframes ac-grow {
from {
transform: scaleY(0.15);
opacity: 0.25;
}
to {
transform: scaleY(1);
opacity: 1;
}
}
@media (prefers-reduced-motion: reduce) {
.ac-bar {
animation: none;
}
}
`;
const sample = [
142, 186, 158, 227, 193, 248, 211, 206, 252, 224, 308, 267, 291, 346,
].map((value, i) => ({ label: `Sep ${i + 1}`, value }));
/** Values are daily totals, ordered oldest to newest. The default data is illustrative. */
export function ActivityCard({
label = "Visitors",
data = sample,
}: {
label?: string;
data?: { label: string; value: number }[];
}) {
const [period, setPeriod] = useState(7);
const points = data.slice(-period).map((point) => ({
...point,
value: Number.isFinite(point.value) ? Math.max(0, point.value) : 0,
}));
const total = points.reduce((sum, point) => sum + point.value, 0);
const maximum = Math.max(1, ...points.map((point) => point.value));
return (
<>
<style>{css}</style>
<section className="ac-card" aria-label={`${label} activity`}>
<div className="ac-top">
<span className="ac-label">
<svg
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
aria-hidden="true"
>
<path d="M4 18V9m5 9V5m5 13v-7m5 7V3" />
</svg>
{label}
</span>
<div className="ac-period" role="group" aria-label="Time period">
{[7, 14].map((days) => (
<button
type="button"
key={days}
aria-pressed={period === days}
onClick={() => setPeriod(days)}
>
{days} days
</button>
))}
</div>
</div>
<p className="ac-number" data-total>
{total.toLocaleString("en-US")}
</p>
<p className="ac-summary" role="status">
{label} across {points.length} {points.length === 1 ? "day" : "days"}
</p>
{points.length > 0 ? (
<>
<ol className="ac-chart" aria-label="Daily totals" key={period}>
{points.map((point, i) => (
<li
key={i}
data-tip={`${point.label}: ${point.value.toLocaleString("en-US")}`}
>
<span
className="ac-bar"
style={{
height: `${(point.value / maximum) * 100}%`,
animationDelay: `${i * 18}ms`,
}}
/>
<span className="ac-tip" aria-hidden="true">
{point.label}: {point.value.toLocaleString("en-US")}
</span>
<span className="ac-sr">
{point.label}: {point.value.toLocaleString("en-US")}
</span>
</li>
))}
</ol>
<div className="ac-axis" aria-hidden="true">
<span>{points[0].label}</span>
<span>{points.at(-1)?.label}</span>
</div>
</>
) : (
<p className="ac-empty">No activity yet.</p>
)}
</section>
</>
);
}