Feedback & Data
ChartBloom
An area chart. The line wipes in from the left, the area blooms up from the baseline, then a crosshair reads any point by pointer, touch or arrow keys. No chart library needed.
Download
- React
- Tailwind CSS v4
- motion
components/ui/chart-bloom.tsx
"use client";
import { useId, useRef, useState, type CSSProperties, type KeyboardEvent, type PointerEvent } from "react";
import { motion, useInView, useReducedMotion } from "motion/react";
type ChartBloomProps = {
/** Points in order, like months. Values are 0 or above. */
data: { label: string; value: number }[];
/** Chart title. Also its name for screen readers. */
label: string;
/** Turns a value into text, like (v) => `$${v}k`. */
format?: (value: number) => string;
/** Plot height in pixels. */
height?: number;
/** Line and area colour. */
accent?: string;
/** Wrapper look (text colour, width). */
className?: string;
};
/* Rounds up to a tidy top value for the axis. */
function niceTop(max: number) {
if (max <= 0) return 1;
const step = 10 ** Math.floor(Math.log10(max));
return [1, 1.5, 2, 3, 5, 7.5, 10].find((m) => m * step >= max)! * step;
}
/* ChartBloom: an area chart. The line wipes in from the left, the area blooms up from the baseline, then a crosshair reads any point by pointer, touch or arrow keys. */
export function ChartBloom({ data, label, format = String, height = 160, accent = "#a78bfa", className = "text-white" }: ChartBloomProps) {
const reduce = useReducedMotion();
const fill = useId();
const box = useRef<HTMLElement>(null);
const seen = useInView(box, { once: true, amount: 0.4 });
const [active, setActive] = useState<number | null>(null);
if (!data.length) return null;
const last = data.length - 1;
const top = niceTop(Math.max(...data.map((d) => d.value)));
const points = data.map((d, i) => ({ x: (i / (last || 1)) * 100, y: 100 - (d.value / top) * 100 }));
const line = points
.map((p, i) => {
if (!i) return `M${p.x} ${p.y}`;
const prev = points[i - 1];
const mid = (prev.x + p.x) / 2;
return `C${mid} ${prev.y} ${mid} ${p.y} ${p.x} ${p.y}`;
})
.join(" ");
const step = Math.ceil(data.length / 6);
const current = active ?? last;
const point = active === null ? null : points[active];
const pick = (e: PointerEvent<HTMLDivElement>) => {
const plot = e.currentTarget.getBoundingClientRect();
const place = Math.round(((e.clientX - plot.left) / plot.width) * last);
setActive(Math.min(last, Math.max(0, place)));
};
const onKey = (e: KeyboardEvent<HTMLDivElement>) => {
const moves: Record<string, number> = { ArrowRight: current + 1, ArrowUp: current + 1, ArrowLeft: current - 1, ArrowDown: current - 1, Home: 0, End: last };
if (!(e.key in moves)) return;
e.preventDefault();
setActive(Math.min(last, Math.max(0, moves[e.key])));
};
return (
<figure ref={box} style={{ "--cb": accent } as CSSProperties} className={`w-full ${className}`}>
<figcaption className="mb-4 text-sm font-medium">{label}</figcaption>
<div className="grid grid-cols-[auto_minmax(0,1fr)] gap-x-2">
<div aria-hidden className="-my-[5.5px] flex flex-col justify-between text-right text-[11px] leading-none text-white/40">
<span>{format(top)}</span>
<span>{format(top / 2)}</span>
<span>{format(0)}</span>
</div>
<div
role="slider"
tabIndex={0}
aria-label={label}
aria-valuemin={0}
aria-valuemax={last}
aria-valuenow={current}
aria-valuetext={`${data[current].label}: ${format(data[current].value)}`}
onPointerMove={pick}
onPointerDown={pick}
onPointerLeave={(e) => e.pointerType === "mouse" && setActive(null)}
onFocus={() => setActive((a) => a ?? last)}
onBlur={() => setActive(null)}
onKeyDown={onKey}
style={{ height }}
className="relative touch-pan-y rounded-sm outline-none focus-visible:ring-2 focus-visible:ring-white/70"
>
{[0, 50, 100].map((y) => (
<span key={y} aria-hidden style={{ top: `${y}%` }} className="absolute inset-x-0 h-px bg-white/10" />
))}
<svg aria-hidden viewBox="0 0 100 100" preserveAspectRatio="none" className="absolute inset-0 size-full">
<defs>
<linearGradient id={fill} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor={accent} stopOpacity={0.4} />
<stop offset="1" stopColor={accent} stopOpacity={0} />
</linearGradient>
</defs>
<motion.path
d={`${line} L100 100 L0 100 Z`}
fill={`url(#${fill})`}
style={{ originY: 1 }}
initial={reduce ? false : { scaleY: 0, opacity: 0 }}
animate={seen ? { scaleY: 1, opacity: 1 } : undefined}
transition={{ type: "spring", stiffness: 70, damping: 13, delay: 0.3 }}
/>
</svg>
<motion.svg
aria-hidden
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="absolute inset-0 size-full overflow-visible"
initial={reduce ? false : { clipPath: "inset(-10% 100% -10% 0%)" }}
animate={seen ? { clipPath: "inset(-10% -2% -10% 0%)" } : undefined}
transition={{ duration: 1, ease: [0.45, 0, 0.2, 1] }}
>
<path d={line} fill="none" stroke={accent} strokeWidth={2} strokeLinecap="round" strokeLinejoin="round" vectorEffect="non-scaling-stroke" />
</motion.svg>
<motion.span
aria-hidden
style={{ left: `${points[last].x}%`, top: `${points[last].y}%` }}
className="absolute -mt-1 -ml-1 size-2 rounded-full bg-(--cb) ring-2 ring-white"
initial={reduce ? false : { scale: 0 }}
animate={seen ? { scale: 1 } : undefined}
transition={{ type: "spring", stiffness: 400, damping: 16, delay: 0.9 }}
/>
{point && (
<div aria-hidden style={{ transform: `translateX(${point.x}%)` }} className="pointer-events-none absolute inset-0 transition-transform duration-150 ease-out motion-reduce:transition-none">
<span className="absolute inset-y-0 left-0 w-px bg-white/30" />
<div style={{ transform: `translateY(${point.y}%)` }} className="absolute inset-0 transition-transform duration-150 ease-out motion-reduce:transition-none">
<span className="absolute top-0 left-0 -mt-1.5 -ml-1.5 size-3 rounded-full bg-(--cb) ring-2 ring-white" />
<div
style={{ transform: `translate(${point.x < 20 ? 0 : point.x > 80 ? -100 : -50}%, ${point.y < 35 ? "12px" : "calc(-100% - 12px)"})` }}
className="absolute top-0 left-0 rounded-lg border border-white/10 bg-[#17151f] px-2.5 py-1.5 text-xs whitespace-nowrap text-white shadow-lg"
>
<span className="text-white/60">{data[current].label}</span> <span className="font-medium tabular-nums">{format(data[current].value)}</span>
</div>
</div>
</div>
)}
</div>
<div aria-hidden className="relative col-start-2 mt-2 h-4 text-[11px] text-white/40">
{data.map((d, i) =>
i % step === 0 ? (
<span key={d.label} style={{ left: `${points[i].x}%` }} className={`absolute top-0 whitespace-nowrap ${i === 0 ? "" : i === last ? "-translate-x-full" : "-translate-x-1/2"}`}>
{d.label}
</span>
) : null,
)}
</div>
</div>
</figure>
);
}
1. Install the dependency.
npm i motion2. Paste the Code tab into
components/ui/chart-bloom.tsx. The styles are Tailwind CSS v4 classes.3. Use it. This is the preview above:
app/chart-bloom-demo.tsx
"use client"; import { ChartBloom } from "@/components/ui/chart-bloom"; const SIGNUPS = [ { label: "Jan", value: 42 }, { label: "Feb", value: 58 }, { label: "Mar", value: 51 }, { label: "Apr", value: 74 }, { label: "May", value: 69 }, { label: "Jun", value: 96 }, { label: "Jul", value: 88 }, { label: "Aug", value: 112 }, { label: "Sep", value: 104 }, { label: "Oct", value: 131 }, ]; export default function ChartBloomExample() { return ( <div className="grid h-full place-items-center bg-[#0b0a14] p-5"> <ChartBloom label="Signups per month" data={SIGNUPS} format={(v) => `${v}k`} height={140} className="max-w-xl text-white" /> </div> ); }
Props
| Name | Type | Default | What it does |
|---|---|---|---|
| data | { label, value }[] | — | Points in order, like months. Values are 0 or above. |
| label | string | — | Chart title. Also its name for screen readers. |
| format | (value: number) => string | String | Turns a value into text for the axis and tooltip. |
| height | number | 160 | Plot height in pixels. |
| accent | string | "#a78bfa" | Line and area colour. |
| className | string | "text-white" | Wrapper look (text colour, width). |