Skip to content
Plinth

Cards

Activity Card

A compact analytics card with an interactive daily chart, computed totals, and a quiet sage palette.

#chart#analytics#bento#dashboard

Install

npx shadcn@latest add https://plinthui.com/r/activity-card.json
Open in v0
"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>
    </>
  );
}

More in Cards