Baktiyar Ablaikhan

Stacked bars

Source
After bencho.dev ↗
Author
Interaction after bencho.dev
Licence
Original code — interaction after bencho.dev

Loading…

Hover a column for its breakdown; switch Day and Week to restack the bars.

Code

"use client";

import { AnimatePresence, motion } from "motion/react";
import { useId, useState } from "react";
import { rng, Surface } from "../ui";

const SERIES = [
  { name: "Direct", color: "#e5493a" },
  { name: "Search", color: "#8a6ff0" },
  { name: "Social", color: "#4c7bf0" },
  { name: "Email", color: "#9db3f2" },
  { name: "Ads", color: "#f2c14e" },
];

function make(seed: number, n: number, scale: number) {
  const r = rng(seed);
  return Array.from({ length: n }, (_, i) => SERIES.map((_, k) => Math.round((k === 0 ? 4 + r() * 3 : 6 + r() * 16 + Math.sin(i * 0.7 + k) * 5) * scale)));
}
const DATA = { Day: make(3, 20, 1), Week: make(8, 20, 6.4) };

export default function StackedBars() {
  const [range, setRange] = useState<"Day" | "Week">("Day");
  const [hot, setHot] = useState<number | null>(null);
  const uid = useId();
  const data = DATA[range];
  const max = Math.max(...data.map((col) => col.reduce((a, b) => a + b, 0)));
  const total = data.reduce((a, col) => a + col.reduce((x, y) => x + y, 0), 0) * 41;

  return (
    <Surface>
      <div className="w-[22em] rounded-[1.2em] bg-white p-[1.1em] shadow-[0_12px_32px_-18px_rgba(0,0,0,0.35)]">
        <div className="flex items-start justify-between">
          <div>
            <p className="text-[0.85em] font-medium">Traffic</p>
            <p className="text-[0.7em] text-[#8a8984]">Last 20 {range === "Day" ? "days" : "weeks"}</p>
          </div>
          <div className="relative flex rounded-full bg-[#f1f0ec] p-[0.2em] text-[0.72em]">
            {(["Day", "Week"] as const).map((r) => (
              <button key={r} type="button" onClick={() => setRange(r)} className={`relative rounded-full px-[0.8em] py-[0.25em] ${range === r ? "" : "text-[#8a8984]"}`}>
                {range === r && <motion.span layoutId={`${uid}-pill`} className="absolute inset-0 rounded-full bg-white shadow-sm" />}
                <span className="relative">{r}</span>
              </button>
            ))}
          </div>
        </div>
        <div className="mt-[0.5em] flex items-baseline gap-[0.4em]">
          <motion.span key={range} initial={{ opacity: 0, y: 6 }} animate={{ opacity: 1, y: 0 }} className="text-[1.7em] font-medium tabular-nums">
            {total.toLocaleString("en-US")}
          </motion.span>
          <span className="text-[0.75em] text-[#8a8984]">visits</span>
        </div>
        <div className="relative mt-[0.6em] flex h-[7.5em] items-end gap-[0.3em]" onPointerLeave={() => setHot(null)}>
          {data.map((col, i) => (
            <div
              key={i}
              className="relative flex h-full flex-1 cursor-pointer flex-col-reverse gap-[0.15em]"
              onPointerEnter={() => setHot(i)}
              onPointerDown={() => setHot(i)}
            >
              {col.map((v, k) => (
                <motion.div
                  key={k}
                  className="w-full rounded-full"
                  style={{ backgroundColor: SERIES[k].color }}
                  initial={false}
                  animate={{ height: `${(v / max) * 86}%`, opacity: hot === null || hot === i ? 1 : 0.28 }}
                  transition={{ type: "spring", stiffness: 260, damping: 26, delay: i * 0.012 + k * 0.01 }}
                />
              ))}
            </div>
          ))}
          <AnimatePresence>
            {hot !== null && (
              <motion.div
                key="tip"
                initial={{ opacity: 0, y: 4 }}
                animate={{ opacity: 1, y: 0, left: `${((hot + 0.5) / data.length) * 100}%` }}
                exit={{ opacity: 0 }}
                transition={{ type: "spring", stiffness: 400, damping: 34 }}
                className="pointer-events-none absolute top-0 z-10 rounded-[0.6em] bg-[#151515] px-[0.7em] py-[0.45em] text-[0.62em] text-white"
                style={{ translateX: hot > 14 ? "-90%" : hot < 4 ? "-10%" : "-50%" }}
              >
                {SERIES.map((s, k) => (
                  <div key={s.name} className="flex items-center gap-[0.5em] whitespace-nowrap">
                    <span className="h-[0.6em] w-[0.6em] rounded-full" style={{ background: s.color }} />
                    <span className="flex-1">{s.name}</span>
                    <span className="tabular-nums">{data[hot][k]}</span>
                  </div>
                ))}
              </motion.div>
            )}
          </AnimatePresence>
        </div>
        <div className="mt-[0.4em] flex justify-between text-[0.65em] text-[#8a8984]">
          <span>{range === "Day" ? "Sep 22" : "Jun 2"}</span>
          <span>{range === "Day" ? "Oct 11" : "Oct 13"}</span>
        </div>
      </div>
    </Surface>
  );
}
Prompt

Build "Stacked bars", a other animation. What it does: Hover a column for its breakdown; switch Day and Week to restack the bars. Write it as a single self-contained React component in TypeScript that fills its parent (w-full h-full), styled with Tailwind CSS, using motion. Technique: an HTML canvas, the Motion library, pointer tracking. It sits on a light background, in a 16:10 frame. Respect prefers-reduced-motion with a still state, pause work when the element is off screen, and keep it accessible. Original: https://bencho.dev (Interaction after bencho.dev, Original code — interaction after bencho.dev).